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"));