diff --git a/augmentations.d.ts b/augmentations.d.ts deleted file mode 100644 index aefc02015..000000000 --- a/augmentations.d.ts +++ /dev/null @@ -1,32 +0,0 @@ -/** - * @dev this augments the mocha context to include some common properties - * we use in our tests - */ -import { BigNumber } from "ethers"; -import { TestAccountsArtBlocks } from "./test/util/common"; - -declare module "mocha" { - export interface Context { - accounts: TestAccountsArtBlocks; - name: string; - symbol: string; - pricePerTokenInWei: BigNumber; - maxInvocations: number; - // project IDs - projectZero: number; - projectOne: number; - projectTwo: number; - projectThree: number; - // token IDs - projectZeroTokenZero: BigNumber; - projectZeroTokenOne: BigNumber; - projectOneTokenZero: BigNumber; - projectOneTokenOne: BigNumber; - projectTwoTokenZero: BigNumber; - projectTwoTokenOne: BigNumber; - projectThreeTokenZero: BigNumber; - projectThreeTokenOne: BigNumber; - // target minter name (e.g. "MinterMerkleV3") - targetMinterName: string | undefined; - } -} diff --git a/contracts/interfaces/0.8.x/IFilteredMinterSEAV0.sol b/contracts/interfaces/0.8.x/IFilteredMinterSEAV0.sol new file mode 100644 index 000000000..67833e377 --- /dev/null +++ b/contracts/interfaces/0.8.x/IFilteredMinterSEAV0.sol @@ -0,0 +1,145 @@ +// SPDX-License-Identifier: GPL-3.0 +// Created By: Art Blocks Inc. + +import "./IFilteredMinterV2.sol"; + +pragma solidity ^0.8.0; + +/** + * @title Interface for MinterSEA, inspired by nouns.wtf. + * This interface combines the set of interfaces that add support for + * a Serial English Auction Minter. + * @author Art Blocks Inc. + */ +interface IFilteredMinterSEAV0 is IFilteredMinterV2 { + /// Struct that defines a single token English auction + struct Auction { + // token number of NFT being auctioned + uint256 tokenId; + // The current highest bid amount (in wei) + uint256 currentBid; + // The address of the current highest bidder + address payable currentBidder; + // The time that the auction is scheduled to end + // max uint64 ~= 1.8e19 sec ~= 570 billion years + uint64 endTime; + // Whether or not the auction has been settled + bool settled; + // Whether or not the auction has been initialized (used to determine + // if auction is the default struct) + bool initialized; + } + + /// Admin-controlled range of allowed auction durations updated + event AuctionDurationSecondsRangeUpdated( + uint32 minAuctionDurationSeconds, + uint32 maxAuctionDurationSeconds + ); + + /// Admin-controlled minimum bid increment percentage updated + event MinterMinBidIncrementPercentageUpdated( + uint8 minterMinBidIncrementPercentage + ); + + /// Admin-controlled time buffer updated + event MinterTimeBufferUpdated(uint32 minterTimeBufferSeconds); + + // Admin-controlled refund gas limit updated + event MinterRefundGasLimitUpdated(uint16 refundGasLimit); + + /// Artist configured future auction details + event ConfiguredFutureAuctions( + uint256 indexed projectId, + uint64 timestampStart, + uint32 auctionDurationSeconds, + uint256 basePrice + ); + + /// Future auction details for project `projectId` reset + event ResetAuctionDetails(uint256 indexed projectId); + + /// New token auction created, token created and sent to minter + event AuctionInitialized( + uint256 indexed tokenId, + address indexed bidder, + uint256 bidAmount, + uint64 endTime + ); + + /// Successful bid placed on token auction + event AuctionBid( + uint256 indexed tokenId, + address indexed bidder, + uint256 bidAmount + ); + + /// Token auction was settled (token distributed to winner) + event AuctionSettled( + uint256 indexed tokenId, + address indexed winner, + uint256 price + ); + + // Next token ID for project `projectId` updated + event ProjectNextTokenUpdated(uint256 indexed projectId, uint256 tokenId); + + // Next token ID for project `projectId` was ejected from the minter + // and is no longer populated + event ProjectNextTokenEjected(uint256 indexed projectId); + + function configureFutureAuctions( + uint256 _projectId, + uint256 _timestampStart, + uint256 _auctionDurationSeconds, + uint256 _basePrice + ) external; + + function resetAuctionDetails(uint256 _projectId) external; + + // artist-only function that populates the next token ID to be auctioned + // for project `projectId` + function tryPopulateNextToken(uint256 _projectId) external; + + function settleAuctionAndCreateBid( + uint256 _settleTokenId, + uint256 _bidTokenId + ) external payable; + + function settleAuction(uint256 _tokenId) external; + + function createBid(uint256 _tokenId) external payable; + + function createBid_l34(uint256 _tokenId) external payable; + + function minterConfigurationDetails() + external + view + returns ( + uint32 minAuctionDurationSeconds_, + uint32 maxAuctionDurationSeconds_, + uint8 minterMinBidIncrementPercentage_, + uint32 minterTimeBufferSeconds_, + uint16 minterRefundGasLimit_ + ); + + function projectConfigurationDetails( + uint256 _projectId + ) + external + view + returns ( + uint24 maxInvocations, + uint64 timestampStart, + uint32 auctionDurationSeconds, + uint256 basePrice, + bool nextTokenNumberIsPopulated, + uint24 nextTokenNumber, + Auction memory auction + ); + + function projectActiveAuctionDetails( + uint256 _projectId + ) external view returns (Auction memory); + + function getTokenToBid(uint256 _projectId) external view returns (uint256); +} diff --git a/contracts/interfaces/0.8.x/IWETH.sol b/contracts/interfaces/0.8.x/IWETH.sol new file mode 100644 index 000000000..2e280c3f0 --- /dev/null +++ b/contracts/interfaces/0.8.x/IWETH.sol @@ -0,0 +1,11 @@ +// SPDX-License-Identifier: GPL-3.0 + +pragma solidity ^0.8.0; + +interface IWETH { + function deposit() external payable; + + function withdraw(uint256 wad) external; + + function transfer(address to, uint256 value) external returns (bool); +} diff --git a/contracts/libs/integration-refs/weth9/weth9.sol b/contracts/libs/integration-refs/weth9/weth9.sol new file mode 100644 index 000000000..ee0ae4906 --- /dev/null +++ b/contracts/libs/integration-refs/weth9/weth9.sol @@ -0,0 +1,763 @@ +// Copyright (C) 2015, 2016, 2017, 2019 Dapphub + +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +// **IMPORTANT**: This contract was copied from the MakerDAO repository, and +// modified to be compatible with Solidity ^0.8.0. The original contract can be +// found at https://github.com/makerdao/ds-weth/blob/master/src/weth9.sol + +pragma solidity >=0.4.23; + +contract WETH9_ { + string public name = "Wrapped Ether"; + string public symbol = "WETH"; + uint8 public decimals = 18; + + event Approval(address indexed src, address indexed guy, uint wad); + event Transfer(address indexed src, address indexed dst, uint wad); + event Deposit(address indexed dst, uint wad); + event Withdrawal(address indexed src, uint wad); + + mapping(address => uint) public balanceOf; + mapping(address => mapping(address => uint)) public allowance; + + receive() external payable { + deposit(); + } + + function deposit() public payable { + balanceOf[msg.sender] += msg.value; + emit Deposit(msg.sender, msg.value); + } + + function withdraw(uint wad) public { + require(balanceOf[msg.sender] >= wad); + balanceOf[msg.sender] -= wad; + payable(msg.sender).transfer(wad); + emit Withdrawal(msg.sender, wad); + } + + function totalSupply() public view returns (uint) { + return address(this).balance; + } + + function approve(address guy, uint wad) public returns (bool) { + allowance[msg.sender][guy] = wad; + emit Approval(msg.sender, guy, wad); + return true; + } + + function transfer(address dst, uint wad) public returns (bool) { + return transferFrom(msg.sender, dst, wad); + } + + function transferFrom( + address src, + address dst, + uint wad + ) public returns (bool) { + require(balanceOf[src] >= wad); + if ( + src != msg.sender && allowance[src][msg.sender] != type(uint256).max + ) { + require(allowance[src][msg.sender] >= wad); + allowance[src][msg.sender] -= wad; + } + + balanceOf[src] -= wad; + balanceOf[dst] += wad; + + emit Transfer(src, dst, wad); + + return true; + } +} + +/* + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. + +*/ diff --git a/contracts/minter-suite/Minters/MinterSEAV0.sol b/contracts/minter-suite/Minters/MinterSEAV0.sol new file mode 100644 index 000000000..9edf043ba --- /dev/null +++ b/contracts/minter-suite/Minters/MinterSEAV0.sol @@ -0,0 +1,1224 @@ +// SPDX-License-Identifier: LGPL-3.0-only +// Created By: Art Blocks Inc. + +pragma solidity 0.8.17; + +import {IWETH} from "../../interfaces/0.8.x/IWETH.sol"; + +import "../../interfaces/0.8.x/IGenArt721CoreContractV3_Base.sol"; +import "../../interfaces/0.8.x/IMinterFilterV0.sol"; +import "../../interfaces/0.8.x/IFilteredMinterSEAV0.sol"; +import "./MinterBase_v0_1_1.sol"; + +import "@openzeppelin-4.7/contracts/token/ERC721/IERC721.sol"; +import "@openzeppelin-4.7/contracts/security/ReentrancyGuard.sol"; +import "@openzeppelin-4.7/contracts/utils/math/SafeCast.sol"; +import "@openzeppelin-4.7/contracts/utils/math/Math.sol"; + +/** + * @title Filtered Minter contract that allows tokens to be minted with ETH. + * Pricing is achieved using an automated serial English Auction mechanism. + * This is designed to be used with GenArt721CoreContractV3 flagship or + * engine contracts. + * @author Art Blocks Inc. + * @notice This contract was inspired by the release mechanism implemented by + * nouns.wtf, and we thank them for their pioneering work in this area. + /********************************* + * ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ * + * ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ * + * ░░░░░░█████████░░█████████░░░ * + * ░░░░░░██░░░████░░██░░░████░░░ * + * ░░██████░░░████████░░░████░░░ * + * ░░██░░██░░░████░░██░░░████░░░ * + * ░░██░░██░░░████░░██░░░████░░░ * + * ░░░░░░█████████░░█████████░░░ * + * ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ * + * ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ * + ********************************* + * @notice Token Ownership: + * This minter contract may own up to two tokens at a time for a given project. + * The first possible token owned is the token that is currently being + * auctioned. During the auction, the token is owned by the minter contract. + * Once the auction ends, the token is transferred to the winning bidder via a + * call to "settle" the auction. + * The second possible token owned is the token that will be auctioned next. + * This token is minted to and owned by the minter contract whenever possible + * (i.e. when the project's max invocations has not been reached) when an + * artist configures their project on this minter, or when a new auction is + * started. The purpose of this token is to allow users to have a preview of + * the next token that will be auctioned, even before the auction has started. + * @notice Privileged Roles and Ownership: + * This contract is designed to be managed, with limited powers. + * Privileged roles and abilities are controlled by the core contract's Admin + * ACL contract and a project's artist. Both of these roles hold extensive + * power and can modify minter details. + * Care must be taken to ensure that the admin ACL contract and artist + * addresses are secure behind a multi-sig or other access control mechanism. + * ---------------------------------------------------------------------------- + * The following functions are restricted to the core contract's Admin ACL + * contract: + * - updateAllowableAuctionDurationSeconds + * - updateMinterMinBidIncrementPercentage + * - updateMinterTimeBufferSeconds + * - ejectNextTokenTo + * ---------------------------------------------------------------------------- + * The following functions are restricted to a project's artist: + * - setProjectMaxInvocations + * - manuallyLimitProjectMaxInvocations + * - configureFutureAuctions + * - tryPopulateNextToken + * ---------------------------------------------------------------------------- + * The following functions are restricted to a project's artist or the core + * contract's Admin ACL contract: + * - resetAuctionDetails + * ---------------------------------------------------------------------------- + * Additional admin and artist privileged roles may be described on other + * contracts that this minter integrates with. + * + * @dev Note that while this minter makes use of `block.timestamp` and it is + * technically possible that this value is manipulated by block producers, such + * manipulation will not have material impact on the ability for collectors to + * place a bid before auction end time. This is due to the admin-configured + * `minterTimeBufferSeconds` parameter, which will used to ensure that + * collectors have sufficient time to place a bid after the final bid and + * before the auction end time. + */ +contract MinterSEAV0 is ReentrancyGuard, MinterBase, IFilteredMinterSEAV0 { + using SafeCast for uint256; + + /// Core contract address this minter interacts with + address public immutable genArt721CoreAddress; + + /// The core contract integrates with V3 contracts + IGenArt721CoreContractV3_Base private immutable genArtCoreContract_Base; + + /// Minter filter address this minter interacts with + address public immutable minterFilterAddress; + + /// Minter filter this minter may interact with. + IMinterFilterV0 private immutable minterFilter; + + /// minterType for this minter + string public constant minterType = "MinterSEAV0"; + + /// minter version for this minter + string public constant minterVersion = "v0.0.1"; + + /// The public WETH contract address + /// @dev WETH is used as fallback payment method when ETH transfers are + /// failing during bidding process (e.g. receive function is not payable) + IWETH public immutable weth; + + uint256 constant ONE_MILLION = 1_000_000; + + // project-specific parameters + struct ProjectConfig { + bool maxHasBeenInvoked; + // max uint24 ~= 1.6e7, > max possible project invocations of 1e6 + uint24 maxInvocations; + // time after which new auctions may be started + // note: new auctions must always be started with a new bid, at which + // point the auction will actually start + // @dev this is a project-level constraint, and individual auctions + // will each have their own start time defined in `activeAuction` + // max uint64 ~= 1.8e19 sec ~= 570 billion years + uint64 timestampStart; + // duration of each new auction, before any extensions due to late bids + uint32 auctionDurationSeconds; + // next token number to be auctioned, owned by minter + // @dev store token number to enable storage packing, as token ID can + // be derived from this value in combination with project ID + // max uint24 ~= 1.6e7, > max possible project invocations of 1e6 + uint24 nextTokenNumber; + // bool to indicate if next token number has been populated, or is + // still default value of 0 + // @dev required to handle edge case where next token number is 0 + bool nextTokenNumberIsPopulated; + // reserve price, i.e. minimum starting bid price, in wei + // @dev for configured auctions, this will be gt 0, so it may be used + // to determine if an auction is configured + uint256 basePrice; + // active auction for project + Auction activeAuction; + } + + mapping(uint256 => ProjectConfig) public projectConfig; + + // minter-wide, admin-configurable parameters + // ---------------------------------------- + // minimum inital auction length, in seconds; configurable by admin + // max uint32 ~= 4.3e9 sec ~= 136 years + // @dev enforced only when artist configures a project + // @dev default to 10 minutes + uint32 minAuctionDurationSeconds = 600; + // maximum inital auction length, in seconds; configurable by admin + // @dev enforced only when artist configures a project + // @dev default to 1 month (1/12 of a year) + uint32 maxAuctionDurationSeconds = 2_629_746; + // the minimum percent increase for new bids above the current bid + // configureable by admin + // max uint8 ~= 255, > 100 percent + // @dev used when determining the increment percentage for any new bid on + // the minter, across all projects + uint8 minterMinBidIncrementPercentage = 5; + // minimum time remaining in auction after a new bid is placed + // configureable by admin + // max uint32 ~= 4.3e9 sec ~= 136 years + // @dev used when determining the buffer time for any new bid on the + // minter, across all projects + uint32 minterTimeBufferSeconds = 120; + // gas limit for refunding ETH to bidders + // configurable by admin, default to 30,000 + // max uint16 = 65,535 to ensure bid refund gas limit remains reasonable + uint16 minterRefundGasLimit = 30_000; + + // modifier-like internal functions + // @dev we use internal functions instead of modifiers to reduce contract + // bytecode size + // ---------------------------------------- + // function to restrict access to only AdminACL allowed calls + // @dev defers to the ACL contract used on the core contract + function _onlyCoreAdminACL(bytes4 _selector) internal { + require( + genArtCoreContract_Base.adminACLAllowed( + msg.sender, + address(this), + _selector + ), + "Only Core AdminACL allowed" + ); + } + + // function to restrict access to only the artist of a project + function _onlyArtist(uint256 _projectId) internal view { + require( + (msg.sender == + genArtCoreContract_Base.projectIdToArtistAddress(_projectId)), + "Only Artist" + ); + } + + // function to restrict access to only the artist of a project or + // AdminACL allowed calls + // @dev defers to the ACL contract used on the core contract + function _onlyCoreAdminACLOrArtist( + uint256 _projectId, + bytes4 _selector + ) internal { + require( + (msg.sender == + genArtCoreContract_Base.projectIdToArtistAddress(_projectId)) || + ( + genArtCoreContract_Base.adminACLAllowed( + msg.sender, + address(this), + _selector + ) + ), + "Only Artist or Admin ACL" + ); + } + + // function to require that a value is non-zero + function _onlyNonZero(uint256 _value) internal pure { + require(_value > 0, "Only non-zero"); + } + + /** + * @notice Initializes contract to be a Filtered Minter for + * `_minterFilter`, integrated with Art Blocks core contract + * at address `_genArt721Address`. + * @param _genArt721Address Art Blocks core contract address for + * which this contract will be a minter. + * @param _minterFilter Minter filter for which + * this will a filtered minter. + * @param _wethAddress The WETH contract address to use for fallback + * payment method when ETH transfers are failing during bidding process + */ + constructor( + address _genArt721Address, + address _minterFilter, + address _wethAddress + ) ReentrancyGuard() MinterBase(_genArt721Address) { + genArt721CoreAddress = _genArt721Address; + genArtCoreContract_Base = IGenArt721CoreContractV3_Base( + _genArt721Address + ); + minterFilterAddress = _minterFilter; + minterFilter = IMinterFilterV0(_minterFilter); + require( + minterFilter.genArt721CoreAddress() == _genArt721Address, + "Illegal contract pairing" + ); + weth = IWETH(_wethAddress); + } + + /** + * @notice Syncs local maximum invocations of project `_projectId` based on + * the value currently defined in the core contract. + * @param _projectId Project ID to set the maximum invocations for. + * @dev this enables gas reduction after maxInvocations have been reached - + * core contracts shall still enforce a maxInvocation check during mint. + */ + function setProjectMaxInvocations(uint256 _projectId) public { + _onlyArtist(_projectId); + uint256 maxInvocations; + uint256 invocations; + (invocations, maxInvocations, , , , ) = genArtCoreContract_Base + .projectStateData(_projectId); + + // update storage with results + projectConfig[_projectId].maxInvocations = uint24(maxInvocations); + + // must ensure maxHasBeenInvoked is correctly set after manually syncing the + // local maxInvocations value with the core contract's maxInvocations value. + // This synced value of maxInvocations from the core contract will always be greater + // than or equal to the previous value of maxInvocations stored locally. + projectConfig[_projectId].maxHasBeenInvoked = + invocations == maxInvocations; + + emit ProjectMaxInvocationsLimitUpdated(_projectId, maxInvocations); + + // for convenience, try to mint and assign a token to the project's + // next slot + _tryMintTokenToNextSlot(_projectId); + } + + /** + * @notice Manually sets the local maximum invocations of project `_projectId` + * with the provided `_maxInvocations`, checking that `_maxInvocations` is less + * than or equal to the value of project `_project_id`'s maximum invocations that is + * set on the core contract. + * @dev Note that a `_maxInvocations` of 0 can only be set if the current `invocations` + * value is also 0 and this would also set `maxHasBeenInvoked` to true, correctly short-circuiting + * this minter's purchase function, avoiding extra gas costs from the core contract's maxInvocations check. + * @param _projectId Project ID to set the maximum invocations for. + * @param _maxInvocations Maximum invocations to set for the project. + */ + function manuallyLimitProjectMaxInvocations( + uint256 _projectId, + uint256 _maxInvocations + ) external { + _onlyArtist(_projectId); + // CHECKS + // ensure that the manually set maxInvocations is not greater than what is set on the core contract + uint256 maxInvocations; + uint256 invocations; + (invocations, maxInvocations, , , , ) = genArtCoreContract_Base + .projectStateData(_projectId); + require( + _maxInvocations <= maxInvocations, + "Cannot increase project max invocations above core contract set project max invocations" + ); + require( + _maxInvocations >= invocations, + "Cannot set project max invocations to less than current invocations" + ); + // EFFECTS + // update storage with results + projectConfig[_projectId].maxInvocations = uint24(_maxInvocations); + // We need to ensure maxHasBeenInvoked is correctly set after manually + // setting the local maxInvocations value. + projectConfig[_projectId].maxHasBeenInvoked = + invocations == _maxInvocations; + + emit ProjectMaxInvocationsLimitUpdated(_projectId, _maxInvocations); + + // for convenience, try to mint and assign a token to the project's + // next slot + _tryMintTokenToNextSlot(_projectId); + } + + /** + * @notice Sets the minimum and maximum values that are settable for + * `durationSeconds` for all project configurations. + * Note that the auction duration is the initial duration of the auction, + * and does not include any extensions that may occur due to new bids being + * placed near the end of an auction. + * @param _minAuctionDurationSeconds Minimum auction duration in seconds. + * @param _maxAuctionDurationSeconds Maximum auction duration in seconds. + */ + function updateAllowableAuctionDurationSeconds( + uint32 _minAuctionDurationSeconds, + uint32 _maxAuctionDurationSeconds + ) external { + _onlyCoreAdminACL(this.updateAllowableAuctionDurationSeconds.selector); + // CHECKS + _onlyNonZero(_minAuctionDurationSeconds); + require( + _maxAuctionDurationSeconds > _minAuctionDurationSeconds, + "Only max gt min" + ); + // EFFECTS + minAuctionDurationSeconds = _minAuctionDurationSeconds; + maxAuctionDurationSeconds = _maxAuctionDurationSeconds; + emit AuctionDurationSecondsRangeUpdated( + _minAuctionDurationSeconds, + _maxAuctionDurationSeconds + ); + } + + /** + * @notice Sets the minter-wide minimum bid increment percentage. New bids + * must be this percent higher than the current top bid to be successful. + * This value should be configured by admin such that appropriate price + * discovery is able to be reached, but gas fees associated with bidding + * wars do not dominate the economics of an auction. + * @dev the input value is considered to be a percentage, so that a value + * of 5 represents 5%. + */ + function updateMinterMinBidIncrementPercentage( + uint8 _minterMinBidIncrementPercentage + ) external { + _onlyCoreAdminACL(this.updateMinterMinBidIncrementPercentage.selector); + // CHECKS + _onlyNonZero(_minterMinBidIncrementPercentage); + // EFFECTS + minterMinBidIncrementPercentage = _minterMinBidIncrementPercentage; + emit MinterMinBidIncrementPercentageUpdated( + _minterMinBidIncrementPercentage + ); + } + + /** + * @notice Sets the minter-wide time buffer in seconds. The time buffer is + * the minimum amount of time that must pass between the final bid and the + * the end of an auction. Auctions are extended if a new bid is placed + * within this time buffer of the auction end time. + */ + function updateMinterTimeBufferSeconds( + uint32 _minterTimeBufferSeconds + ) external { + _onlyCoreAdminACL(this.updateMinterTimeBufferSeconds.selector); + // CHECKS + _onlyNonZero(_minterTimeBufferSeconds); + // EFFECTS + minterTimeBufferSeconds = _minterTimeBufferSeconds; + emit MinterTimeBufferUpdated(_minterTimeBufferSeconds); + } + + /** + * @notice Sets the gas limit during ETH refunds when a collector is + * outbid. This value should be set to a value that is high enough to + * ensure that refunds are successful for commonly used wallets, but low + * enough to avoid excessive abuse of refund gas allowance during a new + * bid. + * @dev max gas limit is 63,535, which is considered a future-safe upper + * bound. + * @param _minterRefundGasLimit Gas limit to set for refunds. Must be between + * 5,000 and max uint16 (63,535). + */ + function updateRefundGasLimit(uint16 _minterRefundGasLimit) external { + _onlyCoreAdminACL(this.updateRefundGasLimit.selector); + // CHECKS + // @dev max gas limit implicitly checked by using uint16 input arg + // @dev min gas limit is based on rounding up current cost to send ETH + // to a Gnosis Safe wallet, which accesses cold address and emits event + require(_minterRefundGasLimit >= 7_000, "Only gte 7_000"); + // EFFECTS + minterRefundGasLimit = _minterRefundGasLimit; + emit MinterRefundGasLimitUpdated(_minterRefundGasLimit); + } + + /** + * @notice Warning: Disabling purchaseTo is not supported on this minter. + * This method exists purely for interface-conformance purposes. + */ + function togglePurchaseToDisabled(uint256 _projectId) external view { + _onlyArtist(_projectId); + revert("Action not supported"); + } + + /** + * @notice Sets auction details for project `_projectId`. + * If project does not have a "next token" assigned, this function attempts + * to mint a token and assign it to the project's next token slot. + * @param _projectId Project ID to set future auction details for. + * @param _timestampStart Timestamp after which new auctions may be + * started. Note that this is not the timestamp of the auction start, but + * rather the timestamp after which a auction may be started. Also note + * that the passed value here must either be in the future, or `0` (which + * indicates that auctions are immediately startable). + * @param _auctionDurationSeconds Duration of new auctions, in seconds, + * before any extensions due to bids being placed inside buffer period near + * the end of an auction. + * @param _basePrice reserve price (minimum starting bid price), in wei. + * Must be greater than 0, but may be as low as 1 wei. + * @dev `_basePrice` of zero not allowed so we can use zero as a gas- + * efficient indicator of whether auctions have been configured for a + * project. + */ + function configureFutureAuctions( + uint256 _projectId, + uint256 _timestampStart, + uint256 _auctionDurationSeconds, + uint256 _basePrice + ) external { + _onlyArtist(_projectId); + // CHECKS + _onlyNonZero(_basePrice); + ProjectConfig storage _projectConfig = projectConfig[_projectId]; + require( + _timestampStart == 0 || block.timestamp < _timestampStart, + "Only future start times or 0" + ); + require( + (_auctionDurationSeconds >= minAuctionDurationSeconds) && + (_auctionDurationSeconds <= maxAuctionDurationSeconds), + "Auction duration out of range" + ); + // EFFECTS + _projectConfig.timestampStart = _timestampStart.toUint64(); + _projectConfig.auctionDurationSeconds = _auctionDurationSeconds + .toUint32(); + _projectConfig.basePrice = _basePrice; + + emit ConfiguredFutureAuctions( + _projectId, + _timestampStart.toUint64(), + _auctionDurationSeconds.toUint32(), + _basePrice + ); + + // sync local max invocations if not initially populated + // @dev if local max invocations and maxHasBeenInvoked are both + // initial values, we know they have not been populated. + if ( + _projectConfig.maxInvocations == 0 && + _projectConfig.maxHasBeenInvoked == false + ) { + setProjectMaxInvocations(_projectId); + // @dev setProjectMaxInvocations function calls + // _tryMintTokenToNextSlot, so we do not call it here. + } else { + // for convenience, try to mint to next token slot + _tryMintTokenToNextSlot(_projectId); + } + } + + /** + * @notice Resets future auction configuration for project `_projectId`, + * zero-ing out all details having to do with future auction parameters. + * This is not intended to be used in normal operation, but rather only in + * case of the need to halt the creation of any future auctions. + * Does not affect any project max invocation details. + * Does not affect any project next token details (i.e. if a next token is + * assigned, it will remain assigned and held by the minter until auction + * details are reconfigured). + * @param _projectId Project ID to reset future auction configuration + * details for. + */ + function resetAuctionDetails(uint256 _projectId) external { + _onlyCoreAdminACLOrArtist( + _projectId, + this.resetAuctionDetails.selector + ); + ProjectConfig storage _projectConfig = projectConfig[_projectId]; + // reset to initial values + _projectConfig.timestampStart = 0; + _projectConfig.auctionDurationSeconds = 0; + _projectConfig.basePrice = 0; + // @dev do not affect next token or max invocations + + emit ResetAuctionDetails(_projectId); + } + + /** + * @notice Admin-only function that ejects a project's "next token" from + * the minter and sends it to the input `_to` address. + * This function is only intended for use in the edge case where the minter + * has a "next token" assigned to a project, but the project has been reset + * via `resetAuctionDetails`, and the artist does not want an auction to be + * started for the "next token". This function also protects against the + * unforseen case where the minter is in an unexpected state where it has a + * "next token" assigned to a project, but for some reason the project is + * unable to begin a new auction due to a bug. + * @dev only a single token may be actively assigned to a project's "next + * token" slot at any given time. This function will eject the token, and + * no further tokens will be assigned to the project's "next token" slot, + * unless the project is subsequently reconfigured via an artist call to + * `configureFutureAuctions`. + * @param _projectId Project ID to eject next token for. + * @param _to Address to send the ejected token to. + */ + function ejectNextTokenTo(uint256 _projectId, address _to) external { + _onlyCoreAdminACL(this.ejectNextTokenTo.selector); + ProjectConfig storage _projectConfig = projectConfig[_projectId]; + // CHECKS + // only if project is not configured (i.e. artist called + // `resetAuctionDetails`) + // @dev we use `basePrice` as a gas-efficient indicator of whether + // auctions have been configured for a project. + require(_projectConfig.basePrice == 0, "Only unconfigured projects"); + // only if minter has a next token assigned + require( + _projectConfig.nextTokenNumberIsPopulated == true, + "No next token" + ); + // EFFECTS + _projectConfig.nextTokenNumberIsPopulated = false; + // INTERACTIONS + // @dev overflow automatically handled by Sol ^0.8.0 + uint256 nextTokenId = (_projectId * ONE_MILLION) + + _projectConfig.nextTokenNumber; + IERC721(genArt721CoreAddress).transferFrom( + address(this), + _to, + nextTokenId + ); + emit ProjectNextTokenEjected(_projectId); + } + + /** + * @notice Emergency, Artist-only function that attempts to mint a new + * token and set it as the the next token to be auctioned for project + * `_projectId`. + * Note: This function is only included for emergency, unforseen use cases, + * and should not be used in normal operation. It is here only for + * redundant protection against an unforseen edge case where the minter + * does not have a populated "next token", but there are still invocations + * remaining on the project. + * This function reverts if the project is not configured on this minter. + * This function returns early and does not modify state when: + * - the minter already has a populated "next token" for the project + * - the project has reached its maximum invocations on the core contract + * or minter + * @dev This function is gated to only the project's artist to prevent + * early minting of tokens by other users. + * @param _projectId The project ID + */ + function tryPopulateNextToken(uint256 _projectId) public nonReentrant { + _onlyArtist(_projectId); + // CHECKS + // revert if project is not configured on this minter + require( + _projectIsConfigured(projectConfig[_projectId]), + "Project not configured" + ); + // INTERACTIONS + // attempt to mint new token to this minter contract, only if max + // invocations has not been reached + _tryMintTokenToNextSlot(_projectId); + } + + /** + * @notice Settles any complete auction for token `_settleTokenId` (if + * applicable), then attempts to create a bid for token + * `_bidTokenId` with bid amount and bidder address equal to + * `msg.value` and `msg.sender`, respectively. + * Intended to gracefully handle the case where a user is front-run by + * one or more transactions to settle and/or initialize a new auction, + * potentially still placing a bid on the auction for the token ID if the + * bid value is sufficiently higher than the current highest bid. + * Note that the use of `_targetTokenId` is to prevent the possibility of + * transactions that are stuck in the pending pool for long periods of time + * from unintentionally bidding on auctions for future tokens. + * Note that calls to `settleAuction` and `createBid` are possible + * to be called in separate transactions, but this function is provided for + * convenience and executes both of those functions in a single + * transaction, while handling front-running as gracefully as possible. + * @param _settleTokenId Token ID to settle auction for. + * @dev this function is not non-reentrant, but the underlying calls are + * to non-reentrant functions. + */ + function settleAuctionAndCreateBid( + uint256 _settleTokenId, + uint256 _bidTokenId + ) external payable { + // ensure tokens are in the same project + require( + _settleTokenId / ONE_MILLION == _bidTokenId / ONE_MILLION, + "Only tokens in same project" + ); + // settle completed auction, if applicable + settleAuction(_settleTokenId); + // attempt to bid on next token + createBid_l34(_bidTokenId); + } + + /** + * @notice Settles a completed auction for `_tokenId`, if it exists and is + * not yet settled. + * Returns early (does not modify state) if + * - there is no initialized auction for the project + * - there is an auction that has already been settled for the project + * - there is an auction for a different token ID on the project + * (likely due to a front-run) + * This function reverts if the auction for `_tokenId` exists, but has not + * yet ended. + * @param _tokenId Token ID to settle auction for. + */ + function settleAuction(uint256 _tokenId) public nonReentrant { + uint256 _projectId = _tokenId / ONE_MILLION; + ProjectConfig storage _projectConfig = projectConfig[_projectId]; + Auction storage _auction = _projectConfig.activeAuction; + // CHECKS + // @dev this check is not strictly necessary, but is included for + // clear error messaging + require(_auction.initialized, "Auction not initialized"); + if (_auction.settled || (_auction.tokenId != _tokenId)) { + // auction already settled or is for a different token ID, so + // return early and do not modify state + return; + } + // @dev important that the following check is after the early return + // block above to maintain desired behavior + require(block.timestamp > _auction.endTime, "Auction not yet ended"); + // EFFECTS + _auction.settled = true; + // INTERACTIONS + // send token to the winning bidder + IERC721(genArt721CoreAddress).transferFrom( + address(this), + _auction.currentBidder, + _tokenId + ); + // distribute revenues from auction + splitRevenuesETH(_projectId, _auction.currentBid, genArt721CoreAddress); + + emit AuctionSettled( + _tokenId, + _auction.currentBidder, + _auction.currentBid + ); + } + + /** + * @notice Enters a bid for token `_tokenId`. + * If an auction for token `_tokenId` does not exist, an auction will be + * initialized as long as any existing auction for the project has been + * settled. + * In order to successfully place the bid, the token bid must be: + * - greater than or equal to a project's minimum bid price if a new + * auction is initialized + * - sufficiently greater than the current highest bid, according to the + * minter's bid increment percentage `minterMinBidIncrementPercentage`, + * if an auction for the token already exists + * If the bid is unsuccessful, the transaction will revert. + * If the bid is successful, but outbid by another bid before the auction + * ends, the funds will be noncustodially returned to the bidder's address, + * `msg.sender`. A fallback method of sending funds back to the bidder via + * WETH is used if the bidder address is not accepting ETH (preventing + * denial of service attacks) within an admin-configured gas limit of + * `minterRefundGasLimit`. + * Note that the use of `_tokenId` is to prevent the possibility of + * transactions that are stuck in the pending pool for long periods of time + * from unintentionally bidding on auctions for future tokens. + * If a new auction is initialized during this call, the project's next + * token will be attempted to be minted to this minter contract, preparing + * it for the next auction. If the project's next token cannot be minted + * due to e.g. reaching the maximum invocations on the core contract or + * minter, the project's next token will not be minted. + * @param _tokenId Token ID being bidded on + */ + function createBid(uint256 _tokenId) external payable { + createBid_l34(_tokenId); + } + + /** + * @notice gas-optimized version of createBid(uint256). + * @dev nonReentrant modifier is used to prevent reentrancy attacks, e.g. + * an an auto-bidder that would be able to atomically outbid a user's + * new bid via a reentrant call to createBid. + */ + function createBid_l34(uint256 _tokenId) public payable nonReentrant { + uint256 _projectId = _tokenId / ONE_MILLION; + // CHECKS + ProjectConfig storage _projectConfig = projectConfig[_projectId]; + Auction storage _auction = _projectConfig.activeAuction; + + // if no auction exists, or current auction is already settled, attempt + // to initialize a new auction for the input token ID and immediately + // return + if ((!_auction.initialized) || _auction.settled) { + _initializeAuctionWithBid(_projectId, _tokenId); + return; + } + // @dev this branch is guaranteed to have an initialized auction that + // not settled, so no need to check for initialized or not settled + + // ensure bids for a specific token ID are only applied to the auction + // for that token ID. + require( + _auction.tokenId == _tokenId, + "Token ID does not match auction" + ); + + // ensure auction is not already ended + require(_auction.endTime > block.timestamp, "Auction already ended"); + + // require bid to be sufficiently greater than current highest bid + // @dev no overflow enforced automatically by solidity ^8.0.0 + require( + msg.value >= + (_auction.currentBid * + (100 + minterMinBidIncrementPercentage)) / + 100, + "Bid is too low" + ); + + // EFFECTS + // record previous highest bid details for refunding + uint256 previousBid = _auction.currentBid; + address payable previousBidder = _auction.currentBidder; + + // update auction state + _auction.currentBid = msg.value; + _auction.currentBidder = payable(msg.sender); + uint256 minEndTime = block.timestamp + minterTimeBufferSeconds; + if (_auction.endTime < minEndTime) { + _auction.endTime = minEndTime.toUint64(); + } + + // INTERACTIONS + // refund previous highest bidder + _safeTransferETHWithFallback(previousBidder, previousBid); + + emit AuctionBid(_tokenId, msg.sender, msg.value); + } + + /** + * @notice Inactive function - see `createBid` or + * `settleAuctionAndCreateBid` + */ + function purchase( + uint256 /*_projectId*/ + ) external payable returns (uint256 /*tokenId*/) { + revert("Inactive function"); + } + + /** + * @notice Inactive function - see `createBid` or + * `settleAuctionAndCreateBid` + */ + function purchaseTo( + address /*_to*/, + uint256 /*_projectId*/ + ) external payable returns (uint256 /*tokenId*/) { + revert("Inactive function"); + } + + /** + * @notice View function to return the current minter-level configuration + * details. + * @return minAuctionDurationSeconds_ Minimum auction duration in seconds + * @return maxAuctionDurationSeconds_ Maximum auction duration in seconds + * @return minterMinBidIncrementPercentage_ Minimum bid increment percentage + * @return minterTimeBufferSeconds_ Buffer time in seconds + * @return minterRefundGasLimit_ Gas limit for refunding ETH + */ + function minterConfigurationDetails() + external + view + returns ( + uint32 minAuctionDurationSeconds_, + uint32 maxAuctionDurationSeconds_, + uint8 minterMinBidIncrementPercentage_, + uint32 minterTimeBufferSeconds_, + uint16 minterRefundGasLimit_ + ) + { + minAuctionDurationSeconds_ = minAuctionDurationSeconds; + maxAuctionDurationSeconds_ = maxAuctionDurationSeconds; + minterMinBidIncrementPercentage_ = minterMinBidIncrementPercentage; + minterTimeBufferSeconds_ = minterTimeBufferSeconds; + minterRefundGasLimit_ = minterRefundGasLimit; + } + + /** + * @notice projectId => has project reached its maximum number of + * invocations? Note that this returns a local cache of the core contract's + * state, and may be out of sync with the core contract. This is + * intentional, as it only enables gas optimization of mints after a + * project's maximum invocations has been reached. A false negative will + * only result in a gas cost increase, since the core contract will still + * enforce a maxInvocation check during minting. A false positive is not + * possible because the V3 core contract only allows maximum invocations + * to be reduced, not increased. Based on this rationale, we intentionally + * do not do input validation in this method as to whether or not the input + * `_projectId` is an existing project ID. + * + */ + function projectMaxHasBeenInvoked( + uint256 _projectId + ) external view returns (bool) { + return projectConfig[_projectId].maxHasBeenInvoked; + } + + /** + * @notice projectId => project's maximum number of invocations. + * Optionally synced with core contract value, for gas optimization. + * Note that this returns a local cache of the core contract's + * state, and may be out of sync with the core contract. This is + * intentional, as it only enables gas optimization of mints after a + * project's maximum invocations has been reached. + * @dev A number greater than the core contract's project max invocations + * will only result in a gas cost increase, since the core contract will + * still enforce a maxInvocation check during minting. A number less than + * the core contract's project max invocations is only possible when the + * project's max invocations have not been synced on this minter, since the + * V3 core contract only allows maximum invocations to be reduced, not + * increased. When this happens, the minter will enable minting, allowing + * the core contract to enforce the max invocations check. Based on this + * rationale, we intentionally do not do input validation in this method as + * to whether or not the input `_projectId` is an existing project ID. + */ + function projectMaxInvocations( + uint256 _projectId + ) external view returns (uint256) { + return uint256(projectConfig[_projectId].maxInvocations); + } + + /** + * @notice projectId => project configuration details. + * Note that in the case of no auction being initialized for the project, + * the returned `auction` will be the default struct. + * @param _projectId The project ID + * @return maxInvocations The project's maximum number of invocations + * allowed on this minter + * @return timestampStart The project's start timestamp, after which new + * auctions may be created (one at a time) + * @return auctionDurationSeconds The project's default auction duration, + * before any extensions due to buffer time + * @return basePrice The project's minimum starting bid price + * @return nextTokenNumberIsPopulated Whether or not the project's next + * token number has been populated + * @return nextTokenNumber The project's next token number to be auctioned, + * dummy value of 0 if `nextTokenNumberIsPopulated` is false. Note that 0 + * is a valid token number, so `nextTokenNumberIsPopulated` should be used + * to distinguish between a valid token number of 0 and a dummy value of 0. + * @return auction The project's active auction details. Will be the + * default struct (w/ `auction.initialized = false`) if no auction has been + * initialized for the project. + */ + function projectConfigurationDetails( + uint256 _projectId + ) + external + view + returns ( + uint24 maxInvocations, + uint64 timestampStart, + uint32 auctionDurationSeconds, + uint256 basePrice, + bool nextTokenNumberIsPopulated, + uint24 nextTokenNumber, + Auction memory auction + ) + { + ProjectConfig storage _projectConfig = projectConfig[_projectId]; + maxInvocations = _projectConfig.maxInvocations; + timestampStart = _projectConfig.timestampStart; + auctionDurationSeconds = _projectConfig.auctionDurationSeconds; + basePrice = _projectConfig.basePrice; + nextTokenNumberIsPopulated = _projectConfig.nextTokenNumberIsPopulated; + nextTokenNumber = _projectConfig.nextTokenNumberIsPopulated + ? _projectConfig.nextTokenNumber + : 0; + auction = _projectConfig.activeAuction; + } + + /** + * @notice projectId => active auction details. + * @dev reverts if no auction exists for the project. + */ + function projectActiveAuctionDetails( + uint256 _projectId + ) external view returns (Auction memory auction) { + ProjectConfig storage _projectConfig = projectConfig[_projectId]; + auction = _projectConfig.activeAuction; + // do not return uninitialized auctions (i.e. auctions that do not + // exist, and therefore are simply the default struct) + require(auction.initialized, "No auction exists on project"); + return auction; + } + + /** + * @notice Convenience function that returns either the current token ID + * being auctioned, or the next expected token ID to be auction if no + * auction is currently initialized or if the current auction has concluded + * (block.timestamp > auction.endTime). + * This is intended to be useful for frontends or scripts that intend to + * call `createBid` or `settleAuctionAndCreateBid`, which requires a + * target bid token ID to be passed in as an argument. + * The function reverts if a project does not have an active auction and + * the next expected token ID has not been populated. + * @param _projectId The project ID being queried + * @return The current token ID being auctioned, or the next token ID to be + * auctioned if a new auction is ready to be created. + */ + function getTokenToBid(uint256 _projectId) external view returns (uint256) { + ProjectConfig storage _projectConfig = projectConfig[_projectId]; + Auction storage _auction = _projectConfig.activeAuction; + // if project has an active token auction that is not settled, return + // that token ID + if (_auction.initialized && (_auction.endTime > block.timestamp)) { + return _auction.tokenId; + } + // otherwise, return the next expected token ID to be auctioned + return getNextTokenId(_projectId); + } + + /** + * @notice View function that returns the next token ID to be auctioned + * by this minter for project `_projectId`. + * Reverts if the next token ID has not been populated for the project. + * @param _projectId The project ID being queried + * @return nextTokenId The next token ID to be auctioned by this minter + */ + function getNextTokenId( + uint256 _projectId + ) public view returns (uint256 nextTokenId) { + ProjectConfig storage _projectConfig = projectConfig[_projectId]; + if (!_projectConfig.nextTokenNumberIsPopulated) { + revert("Next token not populated"); + } + // @dev overflow automatically checked in Solidity ^0.8.0 + nextTokenId = + (_projectId * ONE_MILLION) + + _projectConfig.nextTokenNumber; + return nextTokenId; + } + + /** + * @dev Internal function to initialize an auction for the next token ID + * on project `_projectId` with a bid of `msg.value` from `msg.sender`. + * This function reverts in any of the following cases: + * - project is not configured on this minter + * - project is configured but has not yet reached its start time + * - project has a current active auction that is not settled + * - insufficient bid amount (msg.value < basePrice) + * - no next token has been minted for the project (artist may need to + * call `tryPopulateNextToken`) + * - `_targetTokenId` does not match the next token ID for the project + * After initializing a new auction, this function attempts to mint a new + * token and assign it to the project's next token slot, in preparation for + * a future token auction. However, if the project has reached its maximum + * invocations on either the core contract or minter, the next token slot + * for the project will remain empty. + * @dev This should be executed in a nonReentrant context to provide redundant + * protection against reentrancy. + */ + function _initializeAuctionWithBid( + uint256 _projectId, + uint256 _targetTokenId + ) internal { + ProjectConfig storage _projectConfig = projectConfig[_projectId]; + Auction storage _auction = _projectConfig.activeAuction; + // CHECKS + // ensure project auctions are configured + // @dev base price of zero indicates auctions are not configured + // because only base price of gt zero is allowed when configuring + require(_projectIsConfigured(_projectConfig), "Project not configured"); + // only initialize new auctions if they meet the start time + // requirement + require( + block.timestamp >= _projectConfig.timestampStart, + "Only gte project start time" + ); + // the following require statement is redundant based on how this + // internal function is called, but it is included for protection + // against future changes that could easily introduce a bug if this + // check is not present + // @dev no cover else branch of next line because unreachable + require( + (!_auction.initialized) || _auction.settled, + "Existing auction not settled" + ); + // require valid bid value + require( + msg.value >= _projectConfig.basePrice, + "Insufficient initial bid" + ); + // require next token number is populated + // @dev this should only be encountered if the project has reached + // its maximum invocations on either the core contract or minter + require( + _projectConfig.nextTokenNumberIsPopulated, + "No next token, check max invocations" + ); + // require next token number is the target token ID + require( + _projectConfig.nextTokenNumber == _targetTokenId % ONE_MILLION, + "Incorrect target token ID" + ); + + // EFFECTS + // create new auction, overwriting previous auction if it exists + uint64 endTime = (block.timestamp + + _projectConfig.auctionDurationSeconds).toUint64(); + _projectConfig.activeAuction = Auction({ + tokenId: _targetTokenId, + currentBid: msg.value, + currentBidder: payable(msg.sender), + endTime: endTime, + settled: false, + initialized: true + }); + // mark next token number as not populated + // @dev intentionally not setting nextTokenNumber to zero to avoid + // unnecessary gas costs + _projectConfig.nextTokenNumberIsPopulated = false; + + // @dev we intentionally emit event here due to potential of early + // return in INTERACTIONS section + emit AuctionInitialized(_targetTokenId, msg.sender, msg.value, endTime); + + // INTERACTIONS + // attempt to mint new token to this minter contract, only if max + // invocations has not been reached + _tryMintTokenToNextSlot(_projectId); + } + + /** + * @notice Internal function that attempts to mint a new token to the next + * token slot for the project `_projectId`. + * This function returns early and does not modify state if + * - the project has reached its maximum invocations on either the core + * contract or minter + * - the project config's `nextTokenNumberIsPopulated` is already true + * @param _projectId The ID of the project to mint a new token for. + */ + function _tryMintTokenToNextSlot(uint256 _projectId) internal { + ProjectConfig storage _projectConfig = projectConfig[_projectId]; + if (_projectConfig.nextTokenNumberIsPopulated) { + return; + } + // INTERACTIONS + // attempt to mint new token to this minter contract, only if max + // invocations has not been reached + // we require up-to-date invocation data to properly handle last token + ( + uint256 coreInvocations, + uint256 coreMaxInvocations, + , + , + , + + ) = genArtCoreContract_Base.projectStateData(_projectId); + uint256 localMaxInvocations = _projectConfig.maxInvocations; + uint256 minMaxInvocations = Math.min( + coreMaxInvocations, + localMaxInvocations + ); + if (coreInvocations >= minMaxInvocations) { + // we have reached the max invocations, so we do not mint a new + // token as the "next token", and leave the next token number as + // not populated + return; + } + // @dev this is an effect after a trusted contract interaction + _projectConfig.nextTokenNumberIsPopulated = true; + // mint a new token to this project's "next token" slot + // @dev this is an interaction with a trusted contract + uint256 nextTokenId = minterFilter.mint( + address(this), + _projectId, + address(this) + ); + // update state to reflect new token number + // @dev state changes after trusted contract interaction + // @dev unchecked is safe because mod 1e6 is guaranteed to be less than + // max uint24 + unchecked { + _projectConfig.nextTokenNumber = uint24(nextTokenId % ONE_MILLION); + } + // update local maxHasBeenInvoked value if necessary + uint256 tokenInvocation = (nextTokenId % ONE_MILLION) + 1; + if (tokenInvocation == localMaxInvocations) { + _projectConfig.maxHasBeenInvoked = true; + } + emit ProjectNextTokenUpdated(_projectId, nextTokenId); + } + + /** + * @notice Transfer ETH. If the ETH transfer fails, wrap the ETH and send it as WETH. + */ + function _safeTransferETHWithFallback(address to, uint256 amount) internal { + (bool success, ) = to.call{value: amount, gas: minterRefundGasLimit}( + "" + ); + if (!success) { + weth.deposit{value: amount}(); + weth.transfer(to, amount); + } + } + + /** + * @notice Determines if a project is configured or not on this minter. + * Uses project config's `basePrice` to determine if project is configured, + * because `basePrice` is the only required field for a project to be + * non-zero when configured. + * @param _projectConfig The project config to check. + */ + function _projectIsConfigured( + ProjectConfig storage _projectConfig + ) internal view returns (bool) { + return _projectConfig.basePrice > 0; + } + + /** + * @notice Gets price info to become the leading bidder on a token auction. + * If artist has not called `configureFutureAuctions` and there is no + * active token auction accepting bids, `isConfigured` will be false, and a + * dummy price of zero is assigned to `tokenPriceInWei`. + * If there is an active auction accepting bids, `isConfigured` will be + * true, and `tokenPriceInWei` will be the sum of the current bid value and + * the minimum bid increment due to the minter's + * `minterMinBidIncrementPercentage`. + * If there is an auction that has ended (no longer accepting bids), but + * the project is configured, `isConfigured` will be true, and + * `tokenPriceInWei` will be the minimum initial bid price for the next + * token auction. + * Also returns currency symbol and address to be being used as payment, + * which for this minter is ETH only. + * @param _projectId Project ID to get price information for. + * @return isConfigured true only if project auctions are configured. + * @return tokenPriceInWei price in wei to become the leading bidder on a + * token auction. + * @return currencySymbol currency symbol for purchases of project on this + * minter. This minter always returns "ETH" + * @return currencyAddress currency address for purchases of project on + * this minter. This minter always returns null address, reserved for ether + */ + function getPriceInfo( + uint256 _projectId + ) + external + view + returns ( + bool isConfigured, + uint256 tokenPriceInWei, + string memory currencySymbol, + address currencyAddress + ) + { + ProjectConfig storage _projectConfig = projectConfig[_projectId]; + Auction storage _auction = _projectConfig.activeAuction; + // base price of zero not allowed when configuring auctions, so use it + // as indicator of whether auctions are configured for the project + bool projectIsConfigured = _projectIsConfigured(_projectConfig); + bool auctionIsAcceptingBids = (_auction.initialized && + block.timestamp < _auction.endTime); + isConfigured = projectIsConfigured || auctionIsAcceptingBids; + // only return non-zero price if auction is configured + if (isConfigured) { + if (auctionIsAcceptingBids) { + // return current bid plus minimum bid increment + // @dev overflow automatically checked in Solidity ^0.8.0 + tokenPriceInWei = + (_auction.currentBid * + (100 + minterMinBidIncrementPercentage)) / + 100; + } else { + // return base (starting) price if if current auction is not + // accepting bids (i.e. the minimum initial bid price for the + // next token auction) + tokenPriceInWei = _projectConfig.basePrice; + } + } + // else leave tokenPriceInWei as default value of zero + currencySymbol = "ETH"; + currencyAddress = address(0); + } +} diff --git a/contracts/mock/DeadReceiverBidderMock.sol b/contracts/mock/DeadReceiverBidderMock.sol new file mode 100644 index 000000000..00241bddf --- /dev/null +++ b/contracts/mock/DeadReceiverBidderMock.sol @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: LGPL-3.0-only + +pragma solidity 0.8.17; + +import {DeadReceiverMock} from "./DeadReceiverMock.sol"; + +/** + * @notice This reverts when receiving Ether. + * Also exposes a createBidOnAuction function to create a bid on an auction + * of a serial English auction minter. + * @dev Mock contract for testing purposes. + */ +contract DeadReceiverBidderMock is DeadReceiverMock { + function createBidOnAuction( + address minter, + uint256 tokenId + ) external payable { + (bool success, ) = minter.call{value: msg.value}( + abi.encodeWithSignature("createBid(uint256)", tokenId) + ); + require(success, "DeadReceiverBidderMock: call failed"); + } +} diff --git a/contracts/mock/GasLimitReceiverBidderMock.sol b/contracts/mock/GasLimitReceiverBidderMock.sol new file mode 100644 index 000000000..93b5bc9cf --- /dev/null +++ b/contracts/mock/GasLimitReceiverBidderMock.sol @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: LGPL-3.0-only + +pragma solidity 0.8.17; + +import {GasLimitReceiverMock} from "./GasLimitReceiverMock.sol"; + +/** + * @notice This reverts when receiving Ether. + * Also exposes a createBidOnAuction function to create a bid on an auction + * of a serial English auction minter. + * @dev Mock contract for testing purposes. + */ +contract GasLimitReceiverBidderMock is GasLimitReceiverMock { + function createBidOnAuction( + address minter, + uint256 tokenId + ) external payable { + (bool success, ) = minter.call{value: msg.value}( + abi.encodeWithSignature("createBid(uint256)", tokenId) + ); + require(success, "GasLimitReceiverBidderMock: call failed"); + } +} diff --git a/contracts/mock/GasLimitReceiverMock.sol b/contracts/mock/GasLimitReceiverMock.sol new file mode 100644 index 000000000..01074b860 --- /dev/null +++ b/contracts/mock/GasLimitReceiverMock.sol @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: LGPL-3.0-only + +pragma solidity 0.8.17; + +/** + * @notice This uses all gas when receiving Ether, useful when testing denial + * of service attacks. + * @dev Mock contract for testing purposes. + */ +contract GasLimitReceiverMock { + string public a = "a string in storage."; + + receive() external payable { + // infinite loop to burn all available gas + while (true) { + a = string.concat(a, " A longer string in storage to burn gas."); + } + } +} diff --git a/contracts/mock/ReentrancySEAMock.sol b/contracts/mock/ReentrancySEAMock.sol new file mode 100644 index 000000000..2d365d940 --- /dev/null +++ b/contracts/mock/ReentrancySEAMock.sol @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: LGPL-3.0-only +// Created By: Art Blocks Inc. + +import "../interfaces/0.8.x/IFilteredMinterSEAV0.sol"; + +pragma solidity ^0.8.0; + +contract ReentrancySEAAutoBidderMock { + uint256 public targetTokenId; + + /** + @notice This function can be called to induce controlled reentrency attacks + on AB minter filter suite. + Note that _priceToPay should be > project price per token to induce refund, + making reentrency possible via fallback function. + */ + function attack( + uint256 _targetTokenId, + address _minterContractAddress, + uint256 _initialBidValue + ) external payable { + targetTokenId = _targetTokenId; + IFilteredMinterSEAV0(_minterContractAddress).createBid{ + value: _initialBidValue + }(_targetTokenId); + } + + // receiver is called when minter sends refunded Ether to this contract, when outbid + receive() external payable { + // auto-rebid + uint256 newBidValue = (msg.value * 110) / 100; + IFilteredMinterSEAV0(msg.sender).createBid{value: newBidValue}( + targetTokenId + ); + } +} diff --git a/scripts/util/constants.ts b/scripts/util/constants.ts index f639ffd1b..dc4e48fa8 100644 --- a/scripts/util/constants.ts +++ b/scripts/util/constants.ts @@ -31,3 +31,10 @@ export const KNOWN_ENGINE_REGISTRIES = { "0xB8559AF91377e5BaB052A4E9a5088cB65a9a4d63", }, }; + +// WETH token addresses on supported networks +// @dev thse are the commonly used WETH9 contracts +export const WETH_ADDRESSES = { + goerli: "0xB4FBF271143F4FBf7B91A5ded31805e42b2208d6", + mainnet: "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", +}; diff --git a/test/minter-suite-minters/Minter.common.ts b/test/minter-suite-minters/Minter.common.ts index 4e91dd46d..5b9180d5d 100644 --- a/test/minter-suite-minters/Minter.common.ts +++ b/test/minter-suite-minters/Minter.common.ts @@ -58,6 +58,8 @@ export const Minter_Common = async (_beforeEach: () => Promise) => { minterType == "MinterPolyptychV0" ) { minterConstructorArgs.push(config.delegationRegistry.address); + } else if (minterType.startsWith("MinterSEA")) { + minterConstructorArgs.push(config.weth.address); } await expectRevert( minterFactory.deploy(...minterConstructorArgs), @@ -113,9 +115,10 @@ export const Minter_Common = async (_beforeEach: () => Promise) => { const config = await loadFixture(_beforeEach); const minterType = await config.minter.minterType(); if (!minterType.startsWith("MinterDAExpSettlementV")) { - // minters above v2 do NOT use onlyCoreWhitelisted modifier for setProjectMaxInvocations + // non-MinterSEA minters above v2 do NOT use onlyCoreWhitelisted modifier for setProjectMaxInvocations const accountToTestWith = - minterType.includes("V0") || minterType.includes("V1") + !minterType.startsWith("MinterSEA") && + (minterType.includes("V0") || minterType.includes("V1")) ? config.accounts.deployer : config.accounts.artist; // minters that don't settle on-chain should support config function @@ -154,7 +157,8 @@ export const Minter_Common = async (_beforeEach: () => Promise) => { } // minters above v2 do NOT use onlyCoreWhitelisted modifier for setProjectMaxInvocations const accountToTestWith = - minterType.includes("V0") || minterType.includes("V1") + !minterType.startsWith("MinterSEA") && + (minterType.includes("V0") || minterType.includes("V1")) ? config.accounts.deployer : config.accounts.artist; // update max invocations to 1 on the core diff --git a/test/minter-suite-minters/SEA/MinterSEAV0.test.ts b/test/minter-suite-minters/SEA/MinterSEAV0.test.ts new file mode 100644 index 000000000..4e78e9561 --- /dev/null +++ b/test/minter-suite-minters/SEA/MinterSEAV0.test.ts @@ -0,0 +1,2082 @@ +import { + BN, + constants, + expectEvent, + expectRevert, + balance, + ether, +} from "@openzeppelin/test-helpers"; + +import { expect } from "chai"; +import { BigNumber } from "ethers"; +import { ethers } from "hardhat"; +import { loadFixture } from "@nomicfoundation/hardhat-network-helpers"; + +// hide nuisance logs about event overloading +import { Logger } from "@ethersproject/logger"; +Logger.setLogLevel(Logger.levels.ERROR); + +import { + T_Config, + getAccounts, + assignDefaultConstants, + deployAndGet, + deployCoreWithMinterFilter, + safeAddProject, + requireBigNumberIsClose, +} from "../../util/common"; +import { ONE_MINUTE, ONE_HOUR, ONE_DAY } from "../../util/constants"; + +import { Minter_Common } from "../Minter.common"; + +// test the following V3 core contract derivatives: +const coreContractsToTest = [ + "GenArt721CoreV3", // flagship V3 core + "GenArt721CoreV3_Explorations", // V3 core explorations contract + "GenArt721CoreV3_Engine", // V3 core engine contract + "GenArt721CoreV3_Engine_Flex", // V3 core engine contract +]; + +const TARGET_MINTER_NAME = "MinterSEAV0"; + +// helper functions + +// helper function to initialize a token auction on project zero +// @dev "user" account is the one who initializes the auction +async function initializeProjectZeroTokenZeroAuction(config: T_Config) { + // advance time to auction start time - 1 second + // @dev this makes next block timestamp equal to auction start time + await ethers.provider.send("evm_mine", [config.startTime - 1]); + // someone initializes the auction + const targetToken = BigNumber.from(config.projectZeroTokenZero.toString()); + await config.minter.connect(config.accounts.user).createBid(targetToken, { + value: config.basePrice, + }); +} + +// helper function to initialize a token auction on project zero, and then +// advance time to the end of the auction, but do not settle the auction +async function initializeProjectZeroTokenZeroAuctionAndAdvanceToEnd( + config: T_Config +) { + await initializeProjectZeroTokenZeroAuction(config); + // advance time to end of auction + await ethers.provider.send("evm_mine", [ + config.startTime + config.defaultAuctionLengthSeconds, + ]); +} + +// helper function to initialize a token auction on project zero, advances to end +// of auction, then settles the auction +async function initializeProjectZeroTokenZeroAuctionAndSettle( + config: T_Config +) { + await initializeProjectZeroTokenZeroAuctionAndAdvanceToEnd(config); + // settle the auction + const targetToken = BigNumber.from(config.projectZeroTokenZero.toString()); + await config.minter.connect(config.accounts.user).settleAuction(targetToken); +} + +/** + * These tests intended to ensure config Filtered Minter integrates properly with + * V3 core contracts, both flagship and explorations. + */ +for (const coreContractName of coreContractsToTest) { + describe(`${TARGET_MINTER_NAME}_${coreContractName}`, async function () { + async function _beforeEach() { + let config: T_Config = { + accounts: await getAccounts(), + }; + config = await assignDefaultConstants(config); + config.basePrice = config.pricePerTokenInWei; + + // deploy and configure minter filter and minter + ({ + genArt721Core: config.genArt721Core, + minterFilter: config.minterFilter, + randomizer: config.randomizer, + } = await deployCoreWithMinterFilter( + config, + coreContractName, + "MinterFilterV1" + )); + + // deploy and configure WETH token + config.weth = await deployAndGet(config, "WETH9_", []); + + // deploy and configure minter + config.targetMinterName = TARGET_MINTER_NAME; + config.minter = await deployAndGet(config, config.targetMinterName, [ + config.genArt721Core.address, + config.minterFilter.address, + config.weth.address, + ]); + config.isEngine = await config.minter.isEngine(); + + await safeAddProject( + config.genArt721Core, + config.accounts.deployer, + config.accounts.artist.address + ); + + await config.genArt721Core + .connect(config.accounts.deployer) + .toggleProjectIsActive(config.projectZero); + + await config.genArt721Core + .connect(config.accounts.artist) + .updateProjectMaxInvocations(config.projectZero, 15); + + await config.genArt721Core + .connect(config.accounts.artist) + .toggleProjectIsPaused(config.projectZero); + + await config.minterFilter + .connect(config.accounts.deployer) + .addApprovedMinter(config.minter.address); + await config.minterFilter + .connect(config.accounts.deployer) + .setMinterForProject(config.projectZero, config.minter.address); + + // configure project zero + const blockNumber = await ethers.provider.getBlockNumber(); + const block = await ethers.provider.getBlock(blockNumber); + config.startTime = block.timestamp + ONE_MINUTE; + await config.minter + .connect(config.accounts.artist) + .configureFutureAuctions( + config.projectZero, + config.startTime, + config.defaultAuctionLengthSeconds, + config.pricePerTokenInWei + ); + + return config; + } + describe("common minter tests", async () => { + await Minter_Common(_beforeEach); + }); + + describe("Artist configuring", async function () { + describe("setProjectMaxInvocations", async function () { + it("allows artist to call", async function () { + const config = await loadFixture(_beforeEach); + await config.minter + .connect(config.accounts.artist) + .setProjectMaxInvocations(config.projectZero); + }); + + it("does not allow non-artist to call", async function () { + const config = await loadFixture(_beforeEach); + await expectRevert( + config.minter + .connect(config.accounts.deployer) + .setProjectMaxInvocations(config.projectZero), + "Only Artist" + ); + }); + + it("reverts for unconfigured/non-existent project", async function () { + const config = await loadFixture(_beforeEach); + // trying to set config on unconfigured project (e.g. 99) should cause + // revert on the underlying CoreContract + expectRevert( + config.minter + .connect(config.accounts.artist) + .setProjectMaxInvocations(99), + "Project ID does not exist" + ); + }); + + // @dev updating of state is checked in Minter_Common tests + }); + + describe("manuallyLimitProjectMaxInvocations", async function () { + it("allows artist to call", async function () { + const config = await loadFixture(_beforeEach); + await config.minter + .connect(config.accounts.artist) + .manuallyLimitProjectMaxInvocations(config.projectZero, 2); + }); + + it("does not allow non-artist to call", async function () { + const config = await loadFixture(_beforeEach); + await expectRevert( + config.minter + .connect(config.accounts.deployer) + .manuallyLimitProjectMaxInvocations(config.projectZero, 2), + "Only Artist" + ); + }); + + it("reverts for unconfigured/non-existent project", async function () { + const config = await loadFixture(_beforeEach); + // trying to set config on unconfigured project (e.g. 99) should cause + // revert on the underlying CoreContract + expectRevert( + config.minter + .connect(config.accounts.artist) + .manuallyLimitProjectMaxInvocations(99, 2), + "Project ID does not exist" + ); + }); + + it("reverts if setting to less than current invocations", async function () { + const config = await loadFixture(_beforeEach); + // invoke one invocation on project zero + await initializeProjectZeroTokenZeroAuction(config); + // should revert when limiting to less than current invocations of one + expectRevert( + config.minter + .connect(config.accounts.artist) + .manuallyLimitProjectMaxInvocations(config.projectZero, 0), + "Cannot set project max invocations to less than current invocations" + ); + }); + + it("does not support manually setting project max invocations to be greater than the project max invocations set on the core contract", async function () { + const config = await loadFixture(_beforeEach); + await expectRevert( + config.minter + .connect(config.accounts.artist) + .manuallyLimitProjectMaxInvocations( + config.projectZero, + config.maxInvocations + 1 + ), + "Cannot increase project max invocations above core contract set project max invocations" + ); + }); + + it("appropriately updates state after calling manuallyLimitProjectMaxInvocations", async function () { + const config = await loadFixture(_beforeEach); + // reduce local maxInvocations to 2 on minter + await config.minter + .connect(config.accounts.artist) + .manuallyLimitProjectMaxInvocations(config.projectZero, 2); + const localMaxInvocations = await config.minter + .connect(config.accounts.artist) + .projectConfig(config.projectZero); + expect(localMaxInvocations.maxInvocations).to.equal(2); + + // mint token 2 as next token on project zero (by initializing a new token auction) + await initializeProjectZeroTokenZeroAuction(config); + + // expect projectMaxHasBeenInvoked to be true + const hasMaxBeenInvoked = + await config.minter.projectMaxHasBeenInvoked(config.projectZero); + expect(hasMaxBeenInvoked).to.be.true; + + // increase invocations on the minter + await config.minter + .connect(config.accounts.artist) + .manuallyLimitProjectMaxInvocations(config.projectZero, 3); + + // expect maxInvocations on the minter to be 3 + const localMaxInvocations2 = await config.minter + .connect(config.accounts.artist) + .projectConfig(config.projectZero); + expect(localMaxInvocations2.maxInvocations).to.equal(3); + + // expect projectMaxHasBeenInvoked to now be false + const hasMaxBeenInvoked2 = + await config.minter.projectMaxHasBeenInvoked(config.projectZero); + expect(hasMaxBeenInvoked2).to.be.false; + + // reduce invocations on the minter + await config.minter + .connect(config.accounts.artist) + .manuallyLimitProjectMaxInvocations(config.projectZero, 2); + + // expect maxInvocations on the minter to be 2 + const localMaxInvocations3 = await config.minter + .connect(config.accounts.artist) + .projectConfig(config.projectZero); + expect(localMaxInvocations3.maxInvocations).to.equal(2); + + // expect projectMaxHasBeenInvoked to now be true + const hasMaxBeenInvoked3 = + await config.minter.projectMaxHasBeenInvoked(config.projectZero); + expect(hasMaxBeenInvoked3).to.be.true; + }); + }); + + describe("configureFutureAuctions", async function () { + it("allows timestamp of zero", async function () { + const config = await loadFixture(_beforeEach); + await config.minter + .connect(config.accounts.artist) + .configureFutureAuctions( + config.projectZero, + 0, + config.defaultAuctionLengthSeconds, + config.basePrice + ); + }); + + it("allows future timestamp", async function () { + const config = await loadFixture(_beforeEach); + await config.minter + .connect(config.accounts.artist) + .configureFutureAuctions( + config.projectZero, + config.startTime + 100, + config.defaultAuctionLengthSeconds, + config.basePrice + ); + }); + + it("does not allow past timestamp", async function () { + const config = await loadFixture(_beforeEach); + await expectRevert( + config.minter + .connect(config.accounts.artist) + .configureFutureAuctions( + config.projectZero, + 1, // gt 0 but not a future timestamp + config.defaultAuctionLengthSeconds, + config.basePrice + ), + "Only future start times or 0" + ); + }); + + it("does allow auction duration inside of minter-allowed range", async function () { + const config = await loadFixture(_beforeEach); + await config.minter + .connect(config.accounts.artist) + .configureFutureAuctions( + config.projectZero, + config.startTime, + config.defaultAuctionLengthSeconds + 1, + config.basePrice + ); + }); + + it("emits event", async function () { + const config = await loadFixture(_beforeEach); + await expect( + config.minter + .connect(config.accounts.artist) + .configureFutureAuctions( + config.projectZero, + config.startTime, + config.defaultAuctionLengthSeconds + 1, + config.basePrice + ) + ) + .to.emit(config.minter, "ConfiguredFutureAuctions") + .withArgs( + config.projectZero, + config.startTime, + config.defaultAuctionLengthSeconds + 1, + config.basePrice + ); + }); + + it("does not allow auction duration outside of minter-allowed range", async function () { + const config = await loadFixture(_beforeEach); + // less than min + await expectRevert( + config.minter + .connect(config.accounts.artist) + .configureFutureAuctions( + config.projectZero, + config.startTime, + 1, + config.basePrice + ), + "Auction duration out of range" + ); + await expectRevert( + config.minter + .connect(config.accounts.artist) + .configureFutureAuctions( + config.projectZero, + config.startTime, + 1_000_000_000_000, + config.basePrice + ), + "Auction duration out of range" + ); + }); + + it("does not allow auction base price of zero", async function () { + const config = await loadFixture(_beforeEach); + // less than min + await expectRevert( + config.minter + .connect(config.accounts.artist) + .configureFutureAuctions( + config.projectZero, + config.startTime, + config.defaultAuctionLengthSeconds, + 0 + ), + "Only non-zero" + ); + }); + }); + }); + + describe("Admin configuring", async function () { + describe("updateAllowableAuctionDurationSeconds", async function () { + it("allows admin to call", async function () { + const config = await loadFixture(_beforeEach); + await config.minter + .connect(config.accounts.deployer) + .updateAllowableAuctionDurationSeconds(100, 200); + }); + + it("does not allow non-admin to call", async function () { + const config = await loadFixture(_beforeEach); + await expectRevert( + config.minter + .connect(config.accounts.artist) + .updateAllowableAuctionDurationSeconds(100, 200), + "Only Core AdminACL allowed" + ); + }); + + it("requires max > min", async function () { + const config = await loadFixture(_beforeEach); + await expectRevert( + config.minter + .connect(config.accounts.deployer) + .updateAllowableAuctionDurationSeconds(100, 100), + "Only max gt min" + ); + }); + + it("requires min > 0", async function () { + const config = await loadFixture(_beforeEach); + await expectRevert( + config.minter + .connect(config.accounts.deployer) + .updateAllowableAuctionDurationSeconds(0, 200), + "Only non-zero" + ); + }); + + it("updates state with changes", async function () { + const config = await loadFixture(_beforeEach); + await config.minter + .connect(config.accounts.deployer) + .updateAllowableAuctionDurationSeconds(101, 201); + const minterConfig = await config.minter.minterConfigurationDetails(); + expect(minterConfig.minAuctionDurationSeconds_).to.equal(101); + expect(minterConfig.maxAuctionDurationSeconds_).to.equal(201); + }); + + it("emits event with updated values", async function () { + const config = await loadFixture(_beforeEach); + await expect( + config.minter + .connect(config.accounts.deployer) + .updateAllowableAuctionDurationSeconds(101, 201) + ) + .to.emit(config.minter, "AuctionDurationSecondsRangeUpdated") + .withArgs(101, 201); + }); + }); + + describe("updateMinterMinBidIncrementPercentage", async function () { + it("allows admin to call", async function () { + const config = await loadFixture(_beforeEach); + await config.minter + .connect(config.accounts.deployer) + .updateMinterMinBidIncrementPercentage(5); + }); + + it("does not allow non-admin to call", async function () { + const config = await loadFixture(_beforeEach); + await expectRevert( + config.minter + .connect(config.accounts.artist) + .updateMinterMinBidIncrementPercentage(5), + "Only Core AdminACL allowed" + ); + }); + + it("requires > 0", async function () { + const config = await loadFixture(_beforeEach); + await expectRevert( + config.minter + .connect(config.accounts.deployer) + .updateMinterMinBidIncrementPercentage(0), + "Only non-zero" + ); + }); + + it("updates state with changes", async function () { + const config = await loadFixture(_beforeEach); + await config.minter + .connect(config.accounts.deployer) + .updateMinterMinBidIncrementPercentage(6); + const minterConfig = await config.minter.minterConfigurationDetails(); + expect(minterConfig.minterMinBidIncrementPercentage_).to.equal(6); + }); + + it("emits event", async function () { + const config = await loadFixture(_beforeEach); + await expect( + config.minter + .connect(config.accounts.deployer) + .updateMinterMinBidIncrementPercentage(6) + ) + .to.emit(config.minter, "MinterMinBidIncrementPercentageUpdated") + .withArgs(6); + }); + }); + + describe("updateMinterTimeBufferSeconds", async function () { + it("allows admin to call", async function () { + const config = await loadFixture(_beforeEach); + await config.minter + .connect(config.accounts.deployer) + .updateMinterTimeBufferSeconds(300); + }); + + it("does not allow non-admin to call", async function () { + const config = await loadFixture(_beforeEach); + await expectRevert( + config.minter + .connect(config.accounts.artist) + .updateMinterTimeBufferSeconds(300), + "Only Core AdminACL allowed" + ); + }); + + it("requires > 0", async function () { + const config = await loadFixture(_beforeEach); + await expectRevert( + config.minter + .connect(config.accounts.deployer) + .updateMinterTimeBufferSeconds(0), + "Only non-zero" + ); + }); + + it("updates state with changes", async function () { + const config = await loadFixture(_beforeEach); + await config.minter + .connect(config.accounts.deployer) + .updateMinterTimeBufferSeconds(301); + const minterConfig = await config.minter.minterConfigurationDetails(); + expect(minterConfig.minterTimeBufferSeconds_).to.equal(301); + }); + + it("emits event", async function () { + const config = await loadFixture(_beforeEach); + await expect( + config.minter + .connect(config.accounts.deployer) + .updateMinterTimeBufferSeconds(301) + ) + .to.emit(config.minter, "MinterTimeBufferUpdated") + .withArgs(301); + }); + }); + + describe("updateRefundGasLimit", async function () { + it("allows admin to call", async function () { + const config = await loadFixture(_beforeEach); + await config.minter + .connect(config.accounts.deployer) + .updateRefundGasLimit(10_000); + }); + + it("does not allow non-admin to call", async function () { + const config = await loadFixture(_beforeEach); + await expectRevert( + config.minter + .connect(config.accounts.artist) + .updateRefundGasLimit(10_000), + "Only Core AdminACL allowed" + ); + }); + + it("requires >= 7_000", async function () { + const config = await loadFixture(_beforeEach); + await expectRevert( + config.minter + .connect(config.accounts.deployer) + .updateRefundGasLimit(6_999), + "Only gte 7_000" + ); + }); + + it("updates state with changes", async function () { + const config = await loadFixture(_beforeEach); + await config.minter + .connect(config.accounts.deployer) + .updateRefundGasLimit(10_000); + const minterConfig = await config.minter.minterConfigurationDetails(); + expect(minterConfig.minterRefundGasLimit_).to.equal(10_000); + }); + + it("emits event", async function () { + const config = await loadFixture(_beforeEach); + await expect( + config.minter + .connect(config.accounts.deployer) + .updateRefundGasLimit(10_000) + ) + .to.emit(config.minter, "MinterRefundGasLimitUpdated") + .withArgs(10_000); + }); + }); + }); + + describe("Artist/Admin configuring", async function () { + describe("ejectNextTokenTo", async function () { + it("does not allow artist to call", async function () { + const config = await loadFixture(_beforeEach); + await expectRevert( + config.minter + .connect(config.accounts.artist) + .ejectNextTokenTo( + config.projectZero, + config.accounts.user.address + ), + "Only Core AdminACL allowed" + ); + }); + + it("allows admin to call", async function () { + const config = await loadFixture(_beforeEach); + // artist resets auction details + await config.minter + .connect(config.accounts.artist) + .resetAuctionDetails(config.projectZero); + // admin ejects next token to user + await config.minter + .connect(config.accounts.deployer) + .ejectNextTokenTo(config.projectZero, config.accounts.user.address); + }); + + it("allows admin to call", async function () { + const config = await loadFixture(_beforeEach); + // artist resets auction details + await config.minter + .connect(config.accounts.artist) + .resetAuctionDetails(config.projectZero); + // admin ejects next token to user + await config.minter + .connect(config.accounts.deployer) + .ejectNextTokenTo(config.projectZero, config.accounts.user.address); + }); + + it("does not allow when project is still configured", async function () { + const config = await loadFixture(_beforeEach); + await expectRevert( + config.minter + .connect(config.accounts.deployer) + .ejectNextTokenTo( + config.projectZero, + config.accounts.user.address + ), + "Only unconfigured projects" + ); + }); + + it("does not allow when a next token is not populated", async function () { + const config = await loadFixture(_beforeEach); + // artist sets minter max invocations to 1 + await config.minter + .connect(config.accounts.artist) + .manuallyLimitProjectMaxInvocations(config.projectZero, 1); + // token 1's auction is began + await initializeProjectZeroTokenZeroAuction(config); + // artist resets auction details + await config.minter + .connect(config.accounts.artist) + .resetAuctionDetails(config.projectZero); + // confirm no next token + const projectConfig = await config.minter.projectConfigurationDetails( + config.projectZero + ); + expect(projectConfig.nextTokenNumberIsPopulated).to.be.false; + // expect failure when admin attempts to eject next token + await expectRevert( + config.minter + .connect(config.accounts.deployer) + .ejectNextTokenTo( + config.projectZero, + config.accounts.user.address + ), + "No next token" + ); + }); + + it("ejects token to the `_to` address", async function () { + const config = await loadFixture(_beforeEach); + // artist resets auction details + await config.minter + .connect(config.accounts.artist) + .resetAuctionDetails(config.projectZero); + // admin ejects next token to user + await config.minter + .connect(config.accounts.deployer) + .ejectNextTokenTo(config.projectZero, config.accounts.user.address); + // confirm next token is owned by user + const targetToken = BigNumber.from( + config.projectZeroTokenZero.toString() + ); + const tokenOwner = await config.genArt721Core.ownerOf(targetToken); + expect(tokenOwner).to.equal(config.accounts.user.address); + }); + + it("emits `ProjectNextTokenEjected` event", async function () { + const config = await loadFixture(_beforeEach); + // artist resets auction details + await config.minter + .connect(config.accounts.artist) + .resetAuctionDetails(config.projectZero); + // admin ejects next token to user end event is emitted + await expect( + config.minter + .connect(config.accounts.deployer) + .ejectNextTokenTo( + config.projectZero, + config.accounts.user.address + ) + ) + .to.emit(config.minter, "ProjectNextTokenEjected") + .withArgs(config.projectZero); + }); + + it("updates state: sets next token is populated to false", async function () { + const config = await loadFixture(_beforeEach); + // artist resets auction details + await config.minter + .connect(config.accounts.artist) + .resetAuctionDetails(config.projectZero); + // confirm next token is populated + let projectConfig = await config.minter.projectConfigurationDetails( + config.projectZero + ); + expect(projectConfig.nextTokenNumberIsPopulated).to.be.true; + // admin ejects next token to user + await config.minter + .connect(config.accounts.deployer) + .ejectNextTokenTo(config.projectZero, config.accounts.user.address); + // confirm next token is no longer populated + projectConfig = await config.minter.projectConfigurationDetails( + config.projectZero + ); + expect(projectConfig.nextTokenNumberIsPopulated).to.be.false; + // expect revert if admin attempts to eject next token again + await expectRevert( + config.minter + .connect(config.accounts.deployer) + .ejectNextTokenTo( + config.projectZero, + config.accounts.user.address + ), + "No next token" + ); + }); + }); + + describe("resetAuctionDetails", async function () { + it("allows admin to call", async function () { + const config = await loadFixture(_beforeEach); + await config.minter + .connect(config.accounts.deployer) + .resetAuctionDetails(config.projectZero); + }); + + it("allows artist to call", async function () { + const config = await loadFixture(_beforeEach); + await config.minter + .connect(config.accounts.deployer) + .resetAuctionDetails(config.projectZero); + }); + + it("does not allow non-[admin|artist] to call", async function () { + const config = await loadFixture(_beforeEach); + await expectRevert( + config.minter + .connect(config.accounts.user) + .resetAuctionDetails(config.projectZero), + "Only Artist or Admin ACL" + ); + }); + + it("updates state with changes with no ongoing auction", async function () { + const config = await loadFixture(_beforeEach); + // no ongoing token auction for project zero + await config.minter + .connect(config.accounts.deployer) + .resetAuctionDetails(config.projectZero); + const projectConfig = await config.minter.projectConfigurationDetails( + config.projectZero + ); + expect(projectConfig.timestampStart).to.equal(0); + expect(projectConfig.auctionDurationSeconds).to.equal(0); + expect(projectConfig.basePrice).to.equal(0); + // confirm that no ongoing token auction for project zero + expect(projectConfig.auction.initialized).to.be.false; + }); + + it("updates state with changes with an ongoing auction", async function () { + const config = await loadFixture(_beforeEach); + // initialize token auction for project zero to enter state with ongoing token auction + await initializeProjectZeroTokenZeroAuction(config); + // reset and check state + await config.minter + .connect(config.accounts.deployer) + .resetAuctionDetails(config.projectZero); + const projectConfig = await config.minter.projectConfigurationDetails( + config.projectZero + ); + expect(projectConfig.timestampStart).to.equal(0); + expect(projectConfig.auctionDurationSeconds).to.equal(0); + expect(projectConfig.basePrice).to.equal(0); + // ongoing token auction for project zero should be unaffected + const targetToken = BigNumber.from( + config.projectZeroTokenZero.toString() + ); + expect(projectConfig.auction.tokenId).to.equal(targetToken); + expect(projectConfig.auction.initialized).to.be.true; + }); + + it("emits event", async function () { + const config = await loadFixture(_beforeEach); + // initialize token auction for project zero to enter state with ongoing token auction + await initializeProjectZeroTokenZeroAuction(config); + // reset and check state + await expect( + config.minter + .connect(config.accounts.deployer) + .resetAuctionDetails(config.projectZero) + ) + .to.emit(config.minter, "ResetAuctionDetails") + .withArgs(config.projectZero); + }); + }); + }); + + describe("tryPopulateNextToken", async function () { + it("reverts when project is not configured", async function () { + const config = await loadFixture(_beforeEach); + // un-configure project zero + await config.minter + .connect(config.accounts.artist) + .resetAuctionDetails(config.projectZero); + // expect revert when trying to populate next token on project zero + await expectRevert( + config.minter + .connect(config.accounts.artist) + .tryPopulateNextToken(config.projectZero), + "Project not configured" + ); + }); + + it("does not revert when project is configured, and next token is already populated", async function () { + const config = await loadFixture(_beforeEach); + // verify next token number is populated + const projectConfig = await config.minter.projectConfigurationDetails( + config.projectZero + ); + expect(projectConfig.nextTokenNumberIsPopulated).to.be.true; + // expect no revert when trying to populate next token on project zero, + // when one is already populated + await config.minter + .connect(config.accounts.artist) + .tryPopulateNextToken(config.projectZero); + }); + + // @dev do not think it is possible to test calling tryPopulateNextToken when a next token is not populated, + // when max invocations is reached (as the minter attempts to auto-populate next token when max invocations + // is changed). Therefore there is no test for that case, but the function remains in the contract in case + // of an unforseen bug or emergency situation. + }); + + describe("togglePurchaseToDisabled", async function () { + it("reverts when calling (Action not supported)", async function () { + const config = await loadFixture(_beforeEach); + await expectRevert( + config.minter + .connect(config.accounts.artist) + .togglePurchaseToDisabled(config.projectZero), + "Action not supported" + ); + }); + }); + + describe("settleAuction", async function () { + it("reverts when no auction initialized on project (for clear error messaging)", async function () { + const config = await loadFixture(_beforeEach); + const targetToken = BigNumber.from( + config.projectZeroTokenZero.toString() + ); + await expectRevert( + config.minter + .connect(config.accounts.user) + .settleAuction(targetToken), + "Auction not initialized" + ); + // verify no state change + const projectconfig = await config.minter.projectConfigurationDetails( + config.projectZero + ); + expect(projectconfig.auction.initialized).to.be.false; + }); + + it("reverts when auction not ended", async function () { + const config = await loadFixture(_beforeEach); + // initialize an auction + await initializeProjectZeroTokenZeroAuction(config); + const targetToken = BigNumber.from( + config.projectZeroTokenZero.toString() + ); + const projectConfig = await config.minter.projectConfigurationDetails( + config.projectZero + ); + expect(projectConfig.auction.tokenId).to.be.equal(targetToken); + await expectRevert( + config.minter + .connect(config.accounts.user) + .settleAuction(targetToken), + "Auction not yet ended" + ); + }); + + it("returns early when attempting to settle token without an auction", async function () { + const config = await loadFixture(_beforeEach); + // initialize an auction for token zero + await initializeProjectZeroTokenZeroAuction(config); + // attempt to settle token one (which has no auction) + const targetToken = BigNumber.from( + config.projectZeroTokenOne.toString() + ); + const tx = await config.minter + .connect(config.accounts.user) + .settleAuction(targetToken); + const receipt = await tx.wait(); + // no `AuctionSettled` event emitted (by requiring zero log length) + expect(receipt.logs.length).to.equal(0); + }); + + it("settles a completed token auction, splits revenue, emits event, and distributes token", async function () { + const config = await loadFixture(_beforeEach); + // initialize an auction for token zero + await initializeProjectZeroTokenZeroAuction(config); + // settle token zero's auction + // advance past end of auction + await ethers.provider.send("evm_mine", [ + config.startTime + config.defaultAuctionLengthSeconds + 1, + ]); + const targetToken = BigNumber.from( + config.projectZeroTokenZero.toString() + ); + // record balances before settle tx + const artistBalanceBefore = await config.accounts.artist.getBalance(); + const deployerBalanceBefore = + await config.accounts.deployer.getBalance(); + + // settle token zero's auction + await expect( + config.minter.connect(config.accounts.user).settleAuction(targetToken) + ) + .to.emit(config.minter, "AuctionSettled") + .withArgs( + targetToken, + config.accounts.user.address, + config.basePrice + ); + // validate balances after settle tx + const artistBalanceAfter = await config.accounts.artist.getBalance(); + const deployerBalanceAfter = + await config.accounts.deployer.getBalance(); + // artist receives 90% of base price for non-engine, 80% for engine + const expectedArtistBalance = config.isEngine + ? artistBalanceBefore.add(config.basePrice.mul(80).div(100)) + : artistBalanceBefore.add(config.basePrice.mul(90).div(100)); + expect(artistBalanceAfter).to.equal(expectedArtistBalance); + expect(deployerBalanceAfter).to.equal( + deployerBalanceBefore.add(config.basePrice.mul(10).div(100)) + ); + // verify token is owned by user + const tokenOwner = await config.genArt721Core.ownerOf(targetToken); + expect(tokenOwner).to.equal(config.accounts.user.address); + }); + + it("returns early when attempting to settle an already-settled auction", async function () { + const config = await loadFixture(_beforeEach); + // initialize an auction for token zero + await initializeProjectZeroTokenZeroAuction(config); + // settle token zero's auction + // advance past end of auction + await ethers.provider.send("evm_mine", [ + config.startTime + config.defaultAuctionLengthSeconds + 1, + ]); + const targetToken = BigNumber.from( + config.projectZeroTokenZero.toString() + ); + await config.minter + .connect(config.accounts.user) + .settleAuction(targetToken); + // attempt to settle token zero's auction again, which should return early + const tx = await config.minter + .connect(config.accounts.user) + .settleAuction(targetToken); + const receipt = await tx.wait(); + // no `AuctionSettled` event emitted (by requiring zero log length) + expect(receipt.logs.length).to.equal(0); + }); + }); + + describe("createBid w/ auction initialization", async function () { + it("attempts to create bid if token auction is already initialized", async function () { + const config = await loadFixture(_beforeEach); + await initializeProjectZeroTokenZeroAuction(config); + // attempt to initialize token zero's auction again, which should be smart and create a bid + const targetToken = BigNumber.from( + config.projectZeroTokenZero.toString() + ); + const nextBidValue = config.basePrice.mul(110).div(100); + await expect( + config.minter + .connect(config.accounts.user2) + .createBid(targetToken, { value: nextBidValue }) + ) + .to.emit(config.minter, "AuctionBid") + .withArgs(targetToken, config.accounts.user2.address, nextBidValue); + }); + + it("emits `ProjectNextTokenUpdated` if new auction is initialized", async function () { + const config = await loadFixture(_beforeEach); + // advance time to auction start time + await ethers.provider.send("evm_mine", [config.startTime]); + // someone initializes the auction + const targetTokenAuction = BigNumber.from( + config.projectZeroTokenZero.toString() + ); + const targetTokenNext = BigNumber.from( + config.projectZeroTokenOne.toString() + ); + await expect( + config.minter + .connect(config.accounts.user) + .createBid(targetTokenAuction, { + value: config.basePrice, + }) + ) + .to.emit(config.minter, "ProjectNextTokenUpdated") + .withArgs(config.projectZero, targetTokenNext); + }); + + describe("CHECKS", async function () { + it("reverts when attempting to initialize auction after project has reached max invocations", async function () { + const config = await loadFixture(_beforeEach); + // limit project zero to 1 invocations (0 remaining) + await config.minter + .connect(config.accounts.artist) + .manuallyLimitProjectMaxInvocations(config.projectZero, 1); + // attempt to initialize token zero's auction, which not revert, but also not mint a new token + // to the project's next token slot + const targetToken = BigNumber.from( + config.projectZeroTokenZero.toString() + ); + // advance to auction start time + await ethers.provider.send("evm_mine", [config.startTime]); + await config.minter + .connect(config.accounts.artist) + .createBid(targetToken, { value: config.basePrice }); + // confirm that the project's next token slot is not populated + const projectConfig = await config.minter.projectConfigurationDetails( + config.projectZero + ); + expect(projectConfig.nextTokenNumberIsPopulated).to.be.false; + expect(projectConfig.nextTokenNumber).to.equal(0); + }); + + it("reverts when project is not configured", async function () { + const config = await loadFixture(_beforeEach); + // reset project zero's configuration + await config.minter + .connect(config.accounts.artist) + .resetAuctionDetails(config.projectZero); + // attempt to initialize token zero's auction, which should revert + const targetToken = BigNumber.from( + config.projectZeroTokenZero.toString() + ); + await expectRevert( + config.minter + .connect(config.accounts.artist) + .createBid(targetToken, { value: config.basePrice }), + "Project not configured" + ); + }); + + it("reverts when start time is in the future", async function () { + const config = await loadFixture(_beforeEach); + // attempt to initialize token zero's auction, which should revert + const targetToken = BigNumber.from( + config.projectZeroTokenZero.toString() + ); + // auction start time is in the future at end of _beforeEach fixture + await expectRevert( + config.minter + .connect(config.accounts.artist) + .createBid(targetToken, { value: config.basePrice }), + "Only gte project start time" + ); + }); + + it("reverts if prior to existing auction being settled", async function () { + const config = await loadFixture(_beforeEach); + // initialize an auction for token zero + await initializeProjectZeroTokenZeroAuctionAndAdvanceToEnd(config); + // attempt to initialize token one's auction, which should revert + const targetToken = BigNumber.from( + config.projectZeroTokenOne.toString() + ); + await expectRevert( + config.minter + .connect(config.accounts.user) + .createBid(targetToken, { value: config.basePrice }), + "Token ID does not match auction" + ); + }); + + it("does not revert if previous auction is settled", async function () { + const config = await loadFixture(_beforeEach); + // initialize an auction for token zero + await initializeProjectZeroTokenZeroAuctionAndSettle(config); + // initializing of token one's auction should be successful + const targetToken = BigNumber.from( + config.projectZeroTokenOne.toString() + ); + await config.minter + .connect(config.accounts.user) + .createBid(targetToken, { value: config.basePrice }); + }); + + it("reverts if minimum bid value is not sent", async function () { + const config = await loadFixture(_beforeEach); + // advance time to auction start time + await ethers.provider.send("evm_mine", [config.startTime]); + // initialize the auction with a msg.value less than minimum bid + const targetToken = BigNumber.from( + config.projectZeroTokenZero.toString() + ); + await expectRevert( + config.minter.connect(config.accounts.user).createBid(targetToken, { + value: config.basePrice.sub(1), + }), + "Insufficient initial bid" + ); + }); + + it("reverts if incorrect target token ID", async function () { + const config = await loadFixture(_beforeEach); + // advance time to auction start time + await ethers.provider.send("evm_mine", [config.startTime]); + // initialize the auction with target token ID of 1, which should + // revert because token zero is the next token to be minted + const bidValue = config.basePrice.add(1); + const targetToken = BigNumber.from( + config.projectZeroTokenOne.toString() // <--- incorrect target token ID + ); + await expectRevert( + config.minter.connect(config.accounts.user).createBid(targetToken, { + value: bidValue, + }), + "Incorrect target token ID" + ); + }); + + // handles edge case where different minter goes past minter max invocations + // and then the original minter attempts to initialize an auction + it("reverts when minter max invocations is exceeded on a different minter", async function () { + const config = await loadFixture(_beforeEach); + // limit minter to 2 invocations (1 remaining) + await config.minter + .connect(config.accounts.artist) + .manuallyLimitProjectMaxInvocations(config.projectZero, 2); + // switch to a different minter + const minter2 = await deployAndGet(config, "MinterSetPriceV4", [ + config.genArt721Core.address, + config.minterFilter.address, + ]); + await config.minterFilter + .connect(config.accounts.deployer) + .addApprovedMinter(minter2.address); + await config.minterFilter + .connect(config.accounts.artist) + .setMinterForProject(config.projectZero, minter2.address); + // mint a token with minter2 + await minter2 + .connect(config.accounts.artist) + .updatePricePerTokenInWei(config.projectZero, 0); + await minter2 + .connect(config.accounts.artist) + .purchase(config.projectZero); + // switch back to SEA minter + await config.minterFilter + .connect(config.accounts.artist) + .setMinterForProject(config.projectZero, config.minter.address); + // advance time to auction start time + await ethers.provider.send("evm_mine", [config.startTime]); + // initialize the auction + const targetToken = BigNumber.from( + config.projectZeroTokenZero.toString() + ); + await config.minter + .connect(config.accounts.user) + .createBid(targetToken, { + value: config.basePrice, + }); + // confirm that new token was not minted to next token number + const projectConfig = await config.minter.projectConfigurationDetails( + config.projectZero + ); + expect(projectConfig.nextTokenNumberIsPopulated).to.be.false; + }); + }); + + describe("EFFECTS", function () { + it("updates auction state correctly when initializing a new auction", async function () { + const config = await loadFixture(_beforeEach); + // advance time to auction start time + // @dev we advance time to start time - 1 so that we can initialize the auction in a block + // with timestamp equal to startTime + await ethers.provider.send("evm_mine", [config.startTime - 1]); + // initialize the auction with a msg.value 1 wei greater than minimum bid + const bidValue = config.basePrice.add(1); + const targetToken = BigNumber.from( + config.projectZeroTokenZero.toString() + ); + await config.minter + .connect(config.accounts.user) + .createBid(targetToken, { + value: bidValue, + }); + // validate auction state + const auction = await config.minter.projectActiveAuctionDetails( + config.projectZero + ); + expect(auction.tokenId).to.equal(targetToken); + expect(auction.currentBid).to.equal(bidValue); + expect(auction.currentBidder).to.equal(config.accounts.user.address); + expect(auction.endTime).to.equal( + config.startTime + config.defaultAuctionLengthSeconds + ); + expect(auction.settled).to.equal(false); + expect(auction.initialized).to.equal(true); + }); + + it("emits event when auction is initialized", async function () { + const config = await loadFixture(_beforeEach); + // advance time to auction start time - 1 + // @dev so next block has timestamp equal to startTime + await ethers.provider.send("evm_mine", [config.startTime - 1]); + // expect event when auction is initialized + const targetToken = BigNumber.from( + config.projectZeroTokenZero.toString() // <--- incorrect target token ID + ); + await expect( + config.minter.connect(config.accounts.user).createBid(targetToken, { + value: config.basePrice, + }) + ) + .to.emit(config.minter, "AuctionInitialized") + .withArgs( + targetToken, + config.accounts.user.address, + config.basePrice, + config.startTime + config.defaultAuctionLengthSeconds + ); + }); + }); + }); + + describe("createBid", function () { + describe("CHECKS", function () { + it("does not revert if auction is not initialized (i.e. auto-initializes)", async function () { + const config = await loadFixture(_beforeEach); + // advance time to auction start time + await ethers.provider.send("evm_mine", [config.startTime]); + await config.minter.connect(config.accounts.user).createBid(0, { + value: config.basePrice, + }); + }); + + it("reverts if different token is active", async function () { + const config = await loadFixture(_beforeEach); + // create an auction for token zero + await initializeProjectZeroTokenZeroAuction(config); + // expect bid on different token to revert + const targetToken = BigNumber.from( + config.projectZeroTokenOne.toString() + ); + await expectRevert( + config.minter.connect(config.accounts.user).createBid(targetToken, { + value: config.basePrice.mul(11).div(10), + }), + "Token ID does not match auction" + ); + }); + + it("reverts if auction has ended", async function () { + const config = await loadFixture(_beforeEach); + // create an auction for token zero + await initializeProjectZeroTokenZeroAuctionAndAdvanceToEnd(config); + // expect bid on ended auction to revert + const targetToken = BigNumber.from( + config.projectZeroTokenZero.toString() + ); + await expectRevert( + config.minter.connect(config.accounts.user).createBid(targetToken, { + value: config.basePrice.mul(11).div(10), + }), + "Auction already ended" + ); + }); + + it("reverts if bid is not sufficiently greater than current bid", async function () { + const config = await loadFixture(_beforeEach); + // create an auction for token zero + await initializeProjectZeroTokenZeroAuction(config); + // expect bid that is not sufficiently greater than current bid to revert + const targetToken = BigNumber.from( + config.projectZeroTokenZero.toString() + ); + await expectRevert( + config.minter.connect(config.accounts.user).createBid(targetToken, { + value: config.basePrice.mul(105).div(100).sub(1), + }), + "Bid is too low" + ); + // expect bid that meets minimum bid requirement to succeed + // @dev default is 5% increase on contract + await config.minter + .connect(config.accounts.user) + .createBid(targetToken, { + value: config.basePrice.mul(105).div(100), + }); + }); + + it("reverts if need to initialize auction, but no next token number is available", async function () { + const config = await loadFixture(_beforeEach); + // artist limits number of tokens to 1 + await config.minter + .connect(config.accounts.artist) + .manuallyLimitProjectMaxInvocations(config.projectZero, 1); + // advance time to auction start time + await initializeProjectZeroTokenZeroAuctionAndSettle(config); + // expect bid on token two to revert + const targetTokenOne = BigNumber.from( + config.projectZeroTokenOne.toString() + ); + await expectRevert( + config.minter + .connect(config.accounts.user) + .createBid(targetTokenOne, { + value: config.basePrice.mul(11).div(10), + }), + "No next token, check max invocations" + ); + }); + }); + + describe("EFFECTS", function () { + it("updates auction state correctly when creating a new bid that does not extend auction", async function () { + const config = await loadFixture(_beforeEach); + // initialize an auction for token zero + await initializeProjectZeroTokenZeroAuction(config); + // create a bid that does not extend auction + const targetToken = BigNumber.from( + config.projectZeroTokenZero.toString() + ); + const newBidValue = config.basePrice.mul(11).div(10); + const bidder = config.accounts.user2; + await config.minter.connect(bidder).createBid(targetToken, { + value: newBidValue, + }); + // validate auction state + const auction = await config.minter.projectActiveAuctionDetails( + config.projectZero + ); + expect(auction.tokenId).to.equal(targetToken); + expect(auction.currentBid).to.equal(newBidValue); + expect(auction.currentBidder).to.equal(bidder.address); + expect(auction.endTime).to.equal( + config.startTime + config.defaultAuctionLengthSeconds + ); + expect(auction.settled).to.equal(false); + expect(auction.initialized).to.equal(true); + }); + + it("updates auction state correctly when creating a new bid that does extend auction", async function () { + const config = await loadFixture(_beforeEach); + // initialize an auction for token zero + await initializeProjectZeroTokenZeroAuction(config); + + // validate initial auction state + const auctionInitial = + await config.minter.projectActiveAuctionDetails(config.projectZero); + expect(auctionInitial.endTime).to.equal( + config.startTime + config.defaultAuctionLengthSeconds + ); + + // admin configure buffer time + const bufferTime = 42; + await config.minter + .connect(config.accounts.deployer) + .updateMinterTimeBufferSeconds(bufferTime); + + // create a bid that does extend auction + const newBidTime = + config.startTime + config.defaultAuctionLengthSeconds - 5; // <--- 5 seconds before auction end + // @dev advance to 1 second before new bid time so bid is placed in block with new bid time + await ethers.provider.send("evm_mine", [newBidTime - 1]); + const targetToken = BigNumber.from( + config.projectZeroTokenZero.toString() + ); + const newBidValue = config.basePrice.mul(11).div(10); + const bidder = config.accounts.user2; + await config.minter.connect(bidder).createBid(targetToken, { + value: newBidValue, + }); + // validate new auction state + const auction = await config.minter.projectActiveAuctionDetails( + config.projectZero + ); + expect(auction.tokenId).to.equal(targetToken); + expect(auction.currentBid).to.equal(newBidValue); + expect(auction.currentBidder).to.equal(bidder.address); + expect(auction.endTime).to.equal(newBidTime + bufferTime); + expect(auction.settled).to.equal(false); + expect(auction.initialized).to.equal(true); + }); + + it("emits a AuctionBid event", async function () { + const config = await loadFixture(_beforeEach); + // initialize an auction for token zero + await initializeProjectZeroTokenZeroAuction(config); + // expect new bid to emit a AuctionBid event + const targetToken = BigNumber.from( + config.projectZeroTokenZero.toString() + ); + const newBidValue = config.basePrice.mul(11).div(10); + const bidder = config.accounts.user2; + await expect( + config.minter.connect(bidder).createBid(targetToken, { + value: newBidValue, + }) + ) + .to.emit(config.minter, "AuctionBid") + .withArgs(targetToken, bidder.address, newBidValue); + }); + + it("returns bid funds to previous bidder when outbid", async function () { + const config = await loadFixture(_beforeEach); + // initialize an auction for token zero + await initializeProjectZeroTokenZeroAuction(config); + // record initial bidder balance + const initialBidderBalance = await config.accounts.user.getBalance(); + // create a bid that should return funds to previous bidder + const targetToken = BigNumber.from( + config.projectZeroTokenZero.toString() + ); + const newBidValue = config.basePrice.mul(11).div(10); + await config.minter + .connect(config.accounts.user2) + .createBid(targetToken, { + value: newBidValue, + }); + // verify that revious bidder was returned funds + const newInitialBidderBalance = + await config.accounts.user.getBalance(); + expect(newInitialBidderBalance).to.equal( + initialBidderBalance.add(config.basePrice) + ); + }); + + it("returns bid funds to previous bidder via WETH fallback when outbid to a dead receiver", async function () { + const config = await loadFixture(_beforeEach); + const deadReceiverBidder = await deployAndGet( + config, + "DeadReceiverBidderMock", + [] + ); + // initialize an auction for token zero + await initializeProjectZeroTokenZeroAuction(config); + // place bid with dead receiver mock + const targetToken = BigNumber.from( + config.projectZeroTokenZero.toString() + ); + const bid2Value = config.basePrice.mul(11).div(10); + await deadReceiverBidder + .connect(config.accounts.user2) + .createBidOnAuction(config.minter.address, targetToken, { + value: bid2Value, + }); + // verify that the dead receiver mock received the funds in WETH as fallback + // when they are outbid + const Bid3Value = bid2Value.mul(11).div(10); + await config.minter + .connect(config.accounts.user) + .createBid(targetToken, { + value: Bid3Value, + }); + const deadReceiverWETHBalance = await config.weth.balanceOf( + deadReceiverBidder.address + ); + expect(deadReceiverWETHBalance).to.equal(bid2Value); + }); + }); + }); + + describe("settleAuctionAndCreateBid", function () { + it("requires settle token and bid token be in same project", async function () { + const config = await loadFixture(_beforeEach); + // initialize and advance to end of auction for token zero + await initializeProjectZeroTokenZeroAuctionAndAdvanceToEnd(config); + // expect revert when calling settleAuctionAndCreateBid with tokens from different projects + const settleTokenId = BigNumber.from( + config.projectZeroTokenZero.toString() + ); + const initializeTokenId = BigNumber.from( + config.projectOneTokenZero.toString() + ); + await expectRevert( + config.minter + .connect(config.accounts.user2) + .settleAuctionAndCreateBid(settleTokenId, initializeTokenId, { + value: config.basePrice, + }), + "Only tokens in same project" + ); + }); + + it("settles and initializes an auction", async function () { + const config = await loadFixture(_beforeEach); + // initialize and advance to end of auction for token zero + await initializeProjectZeroTokenZeroAuctionAndAdvanceToEnd(config); + // verify that the auction has not been settled + const auction = await config.minter.projectActiveAuctionDetails( + config.projectZero + ); + expect(auction.settled).to.equal(false); + // settle and initialize a new auction + const settleTokenId = BigNumber.from( + config.projectZeroTokenZero.toString() + ); + const initializeTokenId = BigNumber.from( + config.projectZeroTokenOne.toString() + ); + await expect( + config.minter + .connect(config.accounts.user2) + .settleAuctionAndCreateBid(settleTokenId, initializeTokenId, { + value: config.basePrice, + }) + ) + .to.emit(config.minter, "AuctionSettled") + .withArgs( + settleTokenId, + config.accounts.user.address, + config.basePrice + ); + // verify that a new auction has been initialized for token ID 1 + const newAuction = await config.minter.projectActiveAuctionDetails( + config.projectZero + ); + expect(newAuction.tokenId).to.equal(initializeTokenId); + expect(newAuction.currentBid).to.equal(config.basePrice); + expect(newAuction.currentBidder).to.equal( + config.accounts.user2.address + ); + expect(newAuction.settled).to.equal(false); + expect(newAuction.initialized).to.equal(true); + // minted next token and populated in project config + const projectConfig = await config.minter.projectConfig( + config.projectZero + ); + expect(projectConfig.nextTokenNumberIsPopulated).to.equal(true); + const targetNextTokenId = BigNumber.from( + config.projectZeroTokenTwo.toString() + ); + expect( + projectConfig.nextTokenNumber + config.projectZero * 1_000_000 + ).to.equal(targetNextTokenId); + }); + + it("initializes new auction when frontrun by another settlement", async function () { + const config = await loadFixture(_beforeEach); + + // initialize and advance to end of auction for token zero + await initializeProjectZeroTokenZeroAuctionAndAdvanceToEnd(config); + // a different user settles the auction + const settleTokenId = BigNumber.from( + config.projectZeroTokenZero.toString() + ); + await config.minter.settleAuction(settleTokenId); + // settle and initialize a new auction still initializes a new auction + const initializeTokenId = BigNumber.from( + config.projectZeroTokenOne.toString() + ); + // advance to new time + const newTargetTime = + config.startTime + config.defaultAuctionLengthSeconds + 100; + await ethers.provider.send("evm_mine", [newTargetTime - 1]); + await expect( + config.minter + .connect(config.accounts.user2) + .settleAuctionAndCreateBid(settleTokenId, initializeTokenId, { + value: config.basePrice, + }) + ) + .to.emit(config.minter, "AuctionInitialized") + .withArgs( + initializeTokenId, + config.accounts.user2.address, + config.basePrice, + newTargetTime + config.defaultAuctionLengthSeconds + ); + // minted next token and populated in project config + const projectConfig = await config.minter.projectConfig( + config.projectZero + ); + expect(projectConfig.nextTokenNumberIsPopulated).to.equal(true); + const targetNextTokenId = BigNumber.from( + config.projectZeroTokenTwo.toString() + ); + expect( + projectConfig.nextTokenNumber + config.projectZero * 1_000_000 + ).to.equal(targetNextTokenId); + }); + + it("attempts to place new bid when frontrun by another settlement and auction initialization", async function () { + const config = await loadFixture(_beforeEach); + + // initialize and advance to end of auction for token zero + await initializeProjectZeroTokenZeroAuctionAndAdvanceToEnd(config); + // a different user settles the auction + const settleTokenId = BigNumber.from( + config.projectZeroTokenZero.toString() + ); + await config.minter.settleAuction(settleTokenId); + // a different user initializes a new auction + const initializeTokenId = BigNumber.from( + config.projectZeroTokenOne.toString() + ); + await config.minter + .connect(config.accounts.additional) + .createBid(initializeTokenId, { + value: config.basePrice, + }); + // settle and initialize a new auction still attempts to place a new bid on the new auction + // advance to new time + const newValidBidValue = config.basePrice.mul(11).div(10); + await expect( + config.minter + .connect(config.accounts.user2) + .settleAuctionAndCreateBid(settleTokenId, initializeTokenId, { + value: newValidBidValue, + }) + ) + .to.emit(config.minter, "AuctionBid") + .withArgs( + initializeTokenId, + config.accounts.user2.address, + newValidBidValue + ); + }); + }); + + describe("handles next token well when project reaches max invocations", function () { + it("auto-populates next token number when project max invocations are manually increased", async function () { + const config = await loadFixture(_beforeEach); + // set project max invocations to 1 + await config.minter + .connect(config.accounts.artist) + .manuallyLimitProjectMaxInvocations(config.projectZero, 1); + // initialize and advance to end of auction for token zero + await initializeProjectZeroTokenZeroAuctionAndAdvanceToEnd(config); + // confirm that next token number is not populated + const initialProjectConfig = await config.minter.projectConfig( + config.projectZero + ); + expect(initialProjectConfig.nextTokenNumberIsPopulated).to.equal(false); + // artist increases max invocations + await config.minter + .connect(config.accounts.artist) + .manuallyLimitProjectMaxInvocations(config.projectZero, 2); + // confirm that next token number is populated + const updatedProjectConfig = await config.minter.projectConfig( + config.projectZero + ); + expect(updatedProjectConfig.nextTokenNumberIsPopulated).to.equal(true); + }); + + it("auto-populates next token number when project max invocations are synced to core contract value", async function () { + const config = await loadFixture(_beforeEach); + // set project max invocations to 1 + await config.minter + .connect(config.accounts.artist) + .manuallyLimitProjectMaxInvocations(config.projectZero, 1); + // initialize and advance to end of auction for token zero + await initializeProjectZeroTokenZeroAuctionAndAdvanceToEnd(config); + // confirm that next token number is not populated + const initialProjectConfig = await config.minter.projectConfig( + config.projectZero + ); + expect(initialProjectConfig.nextTokenNumberIsPopulated).to.equal(false); + // artist increases max invocations to equal core contract value + await config.minter + .connect(config.accounts.artist) + .setProjectMaxInvocations(config.projectZero); + // confirm that next token number is populated + const updatedProjectConfig = await config.minter.projectConfig( + config.projectZero + ); + expect(updatedProjectConfig.nextTokenNumberIsPopulated).to.equal(true); + }); + }); + + describe("purchase", function () { + it("is an inactive function", async function () { + const config = await loadFixture(_beforeEach); + await expectRevert( + config.minter.connect(config.accounts.user).purchase(0), + "Inactive function" + ); + }); + }); + + describe("purchaseTo", function () { + it("is an inactive function", async function () { + const config = await loadFixture(_beforeEach); + await expectRevert( + config.minter + .connect(config.accounts.user) + .purchaseTo(config.accounts.user.address, 0), + "Inactive function" + ); + }); + }); + + describe("view functions", function () { + describe("getTokenToBid", function () { + it("reverts when project has already reached max invocations on core contract, and no active auction", async function () { + const config = await loadFixture(_beforeEach); + // set project max invocations to 1 on core contract + await config.genArt721Core + .connect(config.accounts.artist) + .updateProjectMaxInvocations(config.projectZero, 1); + // initialize auction, which mints token zero + await initializeProjectZeroTokenZeroAuctionAndAdvanceToEnd(config); + // view function to get next token ID should revert, since there is no next token, and current auction has + // reached end time + await expectRevert( + config.minter.getTokenToBid(config.projectZero), + "Next token not populated" + ); + }); + + it("reverts when project has already reached max invocations on minter, and no active auction", async function () { + const config = await loadFixture(_beforeEach); + // set project max invocations to 1 on minter + await config.minter + .connect(config.accounts.artist) + .manuallyLimitProjectMaxInvocations(config.projectZero, 1); + // initialize auction, which mints token zero + await initializeProjectZeroTokenZeroAuctionAndAdvanceToEnd(config); + // view function to get next token ID should revert, since project has reached max invocations on minter + await expectRevert( + config.minter.getTokenToBid(config.projectZero), + "Next token not populated" + ); + }); + + it("returns current token auction when project has already reached max invocations on core contract, but there is an active auction", async function () { + const config = await loadFixture(_beforeEach); + // set project max invocations to 1 on core contract + await config.genArt721Core + .connect(config.accounts.artist) + .updateProjectMaxInvocations(config.projectZero, 1); + // initialize auction, which mints token zero + await initializeProjectZeroTokenZeroAuction(config); + // view function to get next token ID should revert, since project has reached max invocations + const targetExpectedTokenId = BigNumber.from( + config.projectZeroTokenZero.toString() + ); + const returnedTokenId = await config.minter.getTokenToBid( + config.projectZero + ); + expect(returnedTokenId).to.equal(targetExpectedTokenId); + }); + + it("returns the next expected token ID when no auction ever initialized on project", async function () { + const config = await loadFixture(_beforeEach); + const targetExpectedTokenId = BigNumber.from( + config.projectZeroTokenZero.toString() + ); + const returnedExpectedTokenId = await config.minter.getTokenToBid( + config.projectZero + ); + expect(returnedExpectedTokenId).to.equal(targetExpectedTokenId); + }); + + it("returns the next expected token ID when active auction has reached end time", async function () { + const config = await loadFixture(_beforeEach); + // initialize auction, which mints token zero + await initializeProjectZeroTokenZeroAuctionAndAdvanceToEnd(config); + const targetExpectedTokenId = BigNumber.from( + config.projectZeroTokenOne.toString() + ); + const returnedExpectedTokenId = await config.minter.getTokenToBid( + config.projectZero + ); + expect(returnedExpectedTokenId).to.equal(targetExpectedTokenId); + }); + }); + + describe("getNextTokenId", function () { + it("reverts when next token is not populated", async function () { + const config = await loadFixture(_beforeEach); + // set project max invocations to 1 + await config.minter + .connect(config.accounts.artist) + .manuallyLimitProjectMaxInvocations(config.projectZero, 1); + // initialize auction, which mints token zero + await initializeProjectZeroTokenZeroAuction(config); + // view function to get next token ID should revert, since project has reached max invocations + // and next token is not populated + await expectRevert( + config.minter.getNextTokenId(config.projectZero), + "Next token not populated" + ); + }); + + it("reverts when a project is not initialized", async function () { + const config = await loadFixture(_beforeEach); + await expectRevert( + config.minter.getNextTokenId(config.projectOne), + "Next token not populated" + ); + }); + + it("returns the next token ID when next token is populated", async function () { + const config = await loadFixture(_beforeEach); + // initialize auction, which mints token zero and populates next token + await initializeProjectZeroTokenZeroAuction(config); + const targetNextTokenId = BigNumber.from( + config.projectZeroTokenOne.toString() + ); + const returnedNextTokenId = await config.minter.getNextTokenId( + config.projectZero + ); + expect(returnedNextTokenId).to.equal(targetNextTokenId); + }); + }); + + describe("getPriceInfo", function () { + it("returns currency symbol and address as ETH and 0x0, respecively", async function () { + const config = await loadFixture(_beforeEach); + const returnedPriceInfo = await config.minter.getPriceInfo( + config.projectZero + ); + expect(returnedPriceInfo.currencySymbol).to.equal("ETH"); + expect(returnedPriceInfo.currencyAddress).to.equal( + constants.ZERO_ADDRESS + ); + }); + + it("returns as unconfigured if project is reset and no auction ever initialized", async function () { + const config = await loadFixture(_beforeEach); + // reset project + await config.minter + .connect(config.accounts.artist) + .resetAuctionDetails(config.projectZero); + // unconfigured price info should be returned + const returnedPriceInfo = await config.minter.getPriceInfo( + config.projectZero + ); + expect(returnedPriceInfo.isConfigured).to.equal(false); + expect(returnedPriceInfo.tokenPriceInWei).to.equal(0); + }); + + it("returns with auction base price if project is configured, but token auction never initialized", async function () { + const config = await loadFixture(_beforeEach); + // configured price info should be returned + const returnedPriceInfo = await config.minter.getPriceInfo( + config.projectZero + ); + expect(returnedPriceInfo.isConfigured).to.equal(true); + expect(returnedPriceInfo.tokenPriceInWei).to.equal( + config.basePrice.toString() + ); + }); + + it("returns with auction base price if project is configured, and current token auction has reached end time", async function () { + const config = await loadFixture(_beforeEach); + await initializeProjectZeroTokenZeroAuctionAndAdvanceToEnd(config); + // configured price info should be returned + const returnedPriceInfo = await config.minter.getPriceInfo( + config.projectZero + ); + expect(returnedPriceInfo.isConfigured).to.equal(true); + expect(returnedPriceInfo.tokenPriceInWei).to.equal( + config.basePrice.toString() + ); + }); + + it("returns with current bid price + minimum bid increment if project is configured, and has active token auction", async function () { + const config = await loadFixture(_beforeEach); + await initializeProjectZeroTokenZeroAuction(config); + // next bid should be at least 5% above current bid + const minimumSubsequentBid = config.basePrice.mul(105).div(100); + // configured price info should be returned + const returnedPriceInfo = await config.minter.getPriceInfo( + config.projectZero + ); + expect(returnedPriceInfo.isConfigured).to.equal(true); + expect(returnedPriceInfo.tokenPriceInWei).to.equal( + minimumSubsequentBid.toString() + ); + }); + + it("returns with current bid price + minimum bid increment if project is NOT configured, and has active token auction", async function () { + const config = await loadFixture(_beforeEach); + await initializeProjectZeroTokenZeroAuction(config); + // reset project + await config.minter + .connect(config.accounts.artist) + .resetAuctionDetails(config.projectZero); + // next bid should be at least 5% above current bid + const minimumSubsequentBid = config.basePrice.mul(105).div(100); + // configured price info should be returned + const returnedPriceInfo = await config.minter.getPriceInfo( + config.projectZero + ); + expect(returnedPriceInfo.isConfigured).to.equal(true); + expect(returnedPriceInfo.tokenPriceInWei).to.equal( + minimumSubsequentBid.toString() + ); + }); + }); + + describe("projectActiveAuctionDetails", function () { + it("reverts if no auction ever initialized on project", async function () { + const config = await loadFixture(_beforeEach); + await expectRevert( + config.minter.projectActiveAuctionDetails(config.projectZero), + "No auction exists on project" + ); + }); + + it("returns expected values when an auction exists", async function () { + const config = await loadFixture(_beforeEach); + await initializeProjectZeroTokenZeroAuction(config); + const auctionDetails = + await config.minter.projectActiveAuctionDetails(config.projectZero); + expect(auctionDetails.tokenId).to.equal( + config.projectZeroTokenZero.toString() + ); + }); + }); + }); + + describe("reentrancy", function () { + describe("createBid_l34", function () { + it("is nonReentrant", async function () { + const config = await loadFixture(_beforeEach); + const autoBidder = await deployAndGet( + config, + "ReentrancySEAAutoBidderMock", + [] + ); + // initialize auction via the auto bidder + await ethers.provider.send("evm_mine", [config.startTime - 1]); + const targetTokenId = BigNumber.from( + config.projectZeroTokenZero.toString() + ); + const initialBidValue = config.basePrice; + await autoBidder.attack( + targetTokenId, + config.minter.address, + initialBidValue, + { value: config.basePrice.mul(5) } + ); + // when outbid, check that auto bidder does not attain reentrancy or DoS attack + const bid2Value = config.basePrice.mul(110).div(100); + await config.minter + .connect(config.accounts.user) + .createBid(targetTokenId, { value: bid2Value }); + // verify that user is the leading bidder, not the auto bidder + const auctionDetails = + await config.minter.projectActiveAuctionDetails(config.projectZero); + expect(auctionDetails.currentBidder).to.equal( + config.accounts.user.address + ); + // verify that the auto bidder received their bid back in weth + const autoBidderWethBalance = await config.weth.balanceOf( + autoBidder.address + ); + expect(autoBidderWethBalance).to.equal(initialBidValue); + }); + }); + + describe("settleAuction", function () { + it("nonReentrant commentary", async function () { + console.log( + "This nonReentrant modifier is implemented to achieve dual redundancy, and therefore is not tested with mock attacking contracts.", + "Primary protection of the function is achieved by following a check-effects-interactions pattern.", + "This is considered sufficient for the purposes of this test suite." + ); + }); + }); + }); + + describe("denial of service / extreme gas usage attack", function () { + it("limits gas consumption when refunding bids with ETH", async function () { + const config = await loadFixture(_beforeEach); + // deploy gas limit bidder + const gasLimitBidder = await deployAndGet( + config, + "GasLimitReceiverBidderMock", + [] + ); + + // initialize auction via the gas limit receiver mock + await ethers.provider.send("evm_mine", [config.startTime - 1]); + const targetTokenId = BigNumber.from( + config.projectZeroTokenZero.toString() + ); + const initialBidValue = config.basePrice; + await gasLimitBidder.createBidOnAuction( + config.minter.address, + targetTokenId, + { value: initialBidValue } + ); + // when outbid, check that gas limit receiver does not use more than 200k gas + const bid2Value = config.basePrice.mul(110).div(100); + const tx = await config.minter + .connect(config.accounts.user) + .createBid(targetTokenId, { value: bid2Value }); + const receipt = await tx.wait(); + expect(receipt.gasUsed).to.be.lte(200000); + // verify state after bid 2 is as expected + // verify that user is the leading bidder, not the auto bidder + const auctionDetails = await config.minter.projectActiveAuctionDetails( + config.projectZero + ); + expect(auctionDetails.currentBidder).to.equal( + config.accounts.user.address + ); + // verify that the auto bidder received their bid back in weth + const bidderWethBalance = await config.weth.balanceOf( + gasLimitBidder.address + ); + expect(bidderWethBalance).to.equal(initialBidValue); + }); + }); + }); +} diff --git a/test/util/common.ts b/test/util/common.ts index 6dde029e6..55c139ee0 100644 --- a/test/util/common.ts +++ b/test/util/common.ts @@ -5,6 +5,7 @@ import { BN } from "@openzeppelin/test-helpers"; import { ethers } from "hardhat"; import type { SignerWithAddress } from "@nomiclabs/hardhat-ethers/signers"; import { Contract, BigNumber } from "ethers"; +import { ONE_MINUTE } from "./constants"; export type TestAccountsArtBlocks = { deployer: SignerWithAddress; @@ -39,6 +40,7 @@ export type T_Config = { // token IDs projectZeroTokenZero?: BigNumber; projectZeroTokenOne?: BigNumber; + projectZeroTokenTwo?: BigNumber; projectOneTokenZero?: BigNumber; projectOneTokenOne?: BigNumber; projectTwoTokenZero?: BigNumber; @@ -55,12 +57,18 @@ export type T_Config = { startTime?: number; auctionStartTimeOffset?: number; targetMinterName?: string; + defaultAuctionLengthSeconds?: number; // contracts genArt721Core?: Contract; randomizer?: Contract; minterFilter?: Contract; minter?: Contract; adminACL?: Contract; + // minter test details + isEngine?: boolean; + delegationRegistry?: Contract; + // ref / mocks + weth?: Contract; }; export async function getAccounts(): Promise { @@ -94,6 +102,7 @@ export async function assignDefaultConstants( config.symbol = "NFT"; config.pricePerTokenInWei = ethers.utils.parseEther("1"); config.maxInvocations = 15; + config.defaultAuctionLengthSeconds = 60 * ONE_MINUTE; // project IDs config.projectZero = projectZero; config.projectOne = projectZero + 1; @@ -104,6 +113,7 @@ export async function assignDefaultConstants( new BN("1000000") ); config.projectZeroTokenOne = config.projectZeroTokenZero.add(new BN("1")); + config.projectZeroTokenTwo = config.projectZeroTokenOne.add(new BN("1")); config.projectOneTokenZero = new BN(config.projectOne).mul(new BN("1000000")); config.projectOneTokenOne = config.projectOneTokenZero.add(new BN("1")); config.projectTwoTokenZero = new BN(config.projectTwo).mul(new BN("1000000"));