diff --git a/.gitignore b/.gitignore index 614d59c..1cca58d 100644 --- a/.gitignore +++ b/.gitignore @@ -9,4 +9,4 @@ cache .env.* dist/ .idea -broadcast \ No newline at end of file +broadcast/ \ No newline at end of file diff --git a/.storage-layout b/.storage-layout index babe995..fddfd12 100644 --- a/.storage-layout +++ b/.storage-layout @@ -30,6 +30,7 @@ | config | struct IERC721Drop.Configuration | 352 | 0 | 64 | src/ERC721Drop.sol:ERC721Drop | | salesConfig | struct IERC721Drop.SalesConfiguration | 354 | 0 | 96 | src/ERC721Drop.sol:ERC721Drop | | presaleMintsByAddress | mapping(address => uint256) | 357 | 0 | 32 | src/ERC721Drop.sol:ERC721Drop | +| royaltyMintSchedule | uint32 | 358 | 0 | 4 | src/ERC721Drop.sol:ERC721Drop | ======================= ➡ ERC721DropProxy diff --git a/addresses/5.json b/addresses/5.json index 419496a..b88b8ce 100644 --- a/addresses/5.json +++ b/addresses/5.json @@ -1,7 +1,11 @@ { - "DROP_METADATA_RENDERER": "0x5956Fd16c4d8c4b4711F2551971aBB7c2F4aF677", + "ZORA_NFT_CREATOR_V1": "0xb4d319458E489825Cea8e25b5e43742BCdAFc6dd", + "ZORA_NFT_CREATOR_PROXY": "0xb9583D05Ba9ba8f7F14CCEe3Da10D2bc0A72f519", + "ZORA_ERC721_TRANSFER_HELPER": "0xd1adAF05575295710dE1145c3c9427c364A70a7f", + "ZORA_FEE_MANAGER": "0xCf5E957CA7b77EC16611992d79f4E179132ad98C", "EDITION_METADATA_RENDERER": "0x2f5C21EF9DdFf9A1FE76a1c55dd5112fcf2EfD39", - "ERC721DROP_IMPL": "0x9dbc5D5Abc25460195e34fF42916eF6D183Cfafd", + "DROP_METADATA_RENDERER": "0x5956Fd16c4d8c4b4711F2551971aBB7c2F4aF677", + "ERC721_DROP": "0x2093eFB737C07324D8b6c5807910EFF45045D987", "FACTORY_UPGRADE_GATE": "0x942C03C7afE5c8118BDB728Aa06d1b894B1cD9A8", "ZORA_NFT_CREATOR_PROXY": "0xb9583D05Ba9ba8f7F14CCEe3Da10D2bc0A72f519", "ZORA_NFT_CREATOR_V1_IMPL": "0x4328cbDAD668E81B475766520E1004e6688D2949" diff --git a/foundry.toml b/foundry.toml index 666531c..b1e7bec 100644 --- a/foundry.toml +++ b/foundry.toml @@ -1,7 +1,7 @@ [profile.default] solc_version = '0.8.17' optimizer = true -optimizer_runs = 5000 +optimizer_runs = 3000 via_ir = true out = 'dist/artifacts' test = 'test' diff --git a/src/ERC721Drop.sol b/src/ERC721Drop.sol index c15c808..e083bdd 100644 --- a/src/ERC721Drop.sol +++ b/src/ERC721Drop.sol @@ -23,6 +23,7 @@ import {AccessControlUpgradeable} from "@openzeppelin/contracts-upgradeable/acce import {ReentrancyGuardUpgradeable} from "@openzeppelin/contracts-upgradeable/security/ReentrancyGuardUpgradeable.sol"; import {MerkleProofUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/cryptography/MerkleProofUpgradeable.sol"; import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; +import {MathUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/math/MathUpgradeable.sol"; import {IMetadataRenderer} from "./interfaces/IMetadataRenderer.sol"; import {IOperatorFilterRegistry} from "./interfaces/IOperatorFilterRegistry.sol"; @@ -35,6 +36,8 @@ import {FundsReceiver} from "./utils/FundsReceiver.sol"; import {Version} from "./utils/Version.sol"; import {PublicMulticall} from "./utils/PublicMulticall.sol"; import {ERC721DropStorageV1} from "./storage/ERC721DropStorageV1.sol"; +import {ERC721DropStorageV2} from "./storage/ERC721DropStorageV2.sol"; + /** * @notice ZORA NFT Base contract for Drops and Editions @@ -55,8 +58,9 @@ contract ERC721Drop is PublicMulticall, OwnableSkeleton, FundsReceiver, - Version(12), - ERC721DropStorageV1 + Version(13), + ERC721DropStorageV1, + ERC721DropStorageV2 { /// @dev This is the max mint batch size for the optimized ERC721A mint contract uint256 internal immutable MAX_MINT_BATCH_SIZE = 8; @@ -83,6 +87,8 @@ contract ERC721Drop is /// @notice Max royalty BPS uint16 constant MAX_ROYALTY_BPS = 50_00; + uint8 constant SUPPLY_ROYALTY_FOR_EVERY_MINT = 1; + // /// @notice Empty string for blank comments // string constant EMPTY_STRING = ""; @@ -440,7 +446,6 @@ contract ERC721Drop is external payable nonReentrant - canMintTokens(quantity) onlyPublicSaleActive returns (uint256) { @@ -455,7 +460,6 @@ contract ERC721Drop is external payable nonReentrant - canMintTokens(quantity) onlyPublicSaleActive returns (uint256) { @@ -463,6 +467,9 @@ contract ERC721Drop is } function _handlePurchase(uint256 quantity, string memory comment) internal returns (uint256) { + _mintSupplyRoyalty(quantity); + _requireCanMintQuantity(quantity); + uint256 salePrice = salesConfig.publicSalePrice; if (msg.value != (salePrice + ZORA_MINT_FEE) * quantity) { @@ -594,7 +601,6 @@ contract ERC721Drop is external payable nonReentrant - canMintTokens(quantity) onlyPresaleActive returns (uint256) { @@ -617,7 +623,6 @@ contract ERC721Drop is external payable nonReentrant - canMintTokens(quantity) onlyPresaleActive returns (uint256) { @@ -631,6 +636,9 @@ contract ERC721Drop is bytes32[] calldata merkleProof, string memory comment ) internal returns (uint256) { + _mintSupplyRoyalty(quantity); + _requireCanMintQuantity(quantity); + if ( !MerkleProofUpgradeable.verify( merkleProof, @@ -1267,6 +1275,37 @@ contract ERC721Drop is emit MintFeePayout(zoraFee, ZORA_MINT_FEE_RECIPIENT, success); } + function _requireCanMintQuantity(uint256 quantity) internal view { + if (quantity + _totalMinted() > config.editionSize) { + revert Mint_SoldOut(); + } + } + + function _mintSupplyRoyalty(uint256 mintQuantity) internal { + uint32 royaltySchedule = royaltyMintSchedule; + if (royaltySchedule == 0) { + return; + } + + address royaltyRecipient = config.fundsRecipient; + if (royaltyRecipient == address(0)) { + return; + } + + uint256 totalRoyaltyMints = (mintQuantity + (_totalMinted() % royaltySchedule)) / (royaltySchedule - 1); + totalRoyaltyMints = MathUpgradeable.min(totalRoyaltyMints, config.editionSize - (mintQuantity + _totalMinted())); + if (totalRoyaltyMints > 0) { + _mintNFTs(royaltyRecipient, totalRoyaltyMints); + } + } + + function updateRoyaltyMintSchedule(uint32 newSchedule) external onlyAdmin { + if (newSchedule == SUPPLY_ROYALTY_FOR_EVERY_MINT) { + revert InvalidMintSchedule(); + } + royaltyMintSchedule = newSchedule; + } + /// @notice ERC165 supports interface /// @param interfaceId interface id to check if supported function supportsInterface(bytes4 interfaceId) diff --git a/src/interfaces/IERC721Drop.sol b/src/interfaces/IERC721Drop.sol index 8d8899f..dd8d6e2 100644 --- a/src/interfaces/IERC721Drop.sol +++ b/src/interfaces/IERC721Drop.sol @@ -70,6 +70,8 @@ interface IERC721Drop { error Admin_InvalidUpgradeAddress(address proposedAddress); /// @notice Unable to finalize an edition not marked as open (size set to uint64_max_value) error Admin_UnableToFinalizeNotOpenEdition(); + /// @notice Cannot reserve every mint for admin + error InvalidMintSchedule(); /// @notice Event emitted for mint fee payout /// @param mintFeeAmount amount of the mint fee diff --git a/src/storage/ERC721DropStorageV2.sol b/src/storage/ERC721DropStorageV2.sol new file mode 100644 index 0000000..05acba6 --- /dev/null +++ b/src/storage/ERC721DropStorageV2.sol @@ -0,0 +1,6 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.10; + +contract ERC721DropStorageV2 { + uint32 public royaltyMintSchedule; +} diff --git a/test/ERC721Drop.t.sol b/test/ERC721Drop.t.sol index 50a0846..cc538e8 100644 --- a/test/ERC721Drop.t.sol +++ b/test/ERC721Drop.t.sol @@ -3,6 +3,7 @@ pragma solidity ^0.8.10; import {Test} from "forge-std/Test.sol"; import {IERC721AUpgradeable} from "erc721a-upgradeable/IERC721AUpgradeable.sol"; +import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; import {ERC721Drop} from "../src/ERC721Drop.sol"; import {DummyMetadataRenderer} from "./utils/DummyMetadataRenderer.sol"; @@ -977,6 +978,144 @@ contract ERC721DropTest is Test { assertEq(dummyRenderer.someState(), ""); } + function test_SupplyRoyaltyMintScheduleCannotBeOne() public setupZoraNFTBase(100) { + vm.startPrank(DEFAULT_OWNER_ADDRESS); + vm.expectRevert(IERC721Drop.InvalidMintSchedule.selector); + zoraNFTBase.updateRoyaltyMintSchedule(1); + } + + function test_SupplyRoyaltyPurchase(uint32 royaltyMintSchedule, uint32 editionSize, uint256 mintQuantity) public setupZoraNFTBase(editionSize) { + vm.assume(royaltyMintSchedule > 1 && royaltyMintSchedule <= editionSize && editionSize <= 100000 && mintQuantity > 0 && mintQuantity <= editionSize); + uint256 totalRoyaltyMintsForSale = editionSize / royaltyMintSchedule; + vm.assume(mintQuantity <= editionSize - totalRoyaltyMintsForSale); + + vm.startPrank(DEFAULT_OWNER_ADDRESS); + + zoraNFTBase.updateRoyaltyMintSchedule(royaltyMintSchedule); + + zoraNFTBase.setSaleConfiguration({ + publicSaleStart: 0, + publicSaleEnd: type(uint64).max, + presaleStart: 0, + presaleEnd: 0, + publicSalePrice: 0.1 ether, + maxSalePurchasePerAddress: editionSize, + presaleMerkleRoot: bytes32(0) + }); + vm.stopPrank(); + + uint256 totalRoyaltyMintsForPurchase = mintQuantity / (royaltyMintSchedule - 1); + totalRoyaltyMintsForPurchase = Math.min(totalRoyaltyMintsForPurchase, editionSize - mintQuantity); + (, uint256 zoraFee) = zoraNFTBase.zoraFeeForAmount(mintQuantity); + + uint256 paymentAmount = 0.1 ether * mintQuantity + zoraFee; + vm.deal(address(456), paymentAmount); + + vm.startPrank(address(456)); + zoraNFTBase.purchase{value: paymentAmount}(mintQuantity); + + assertEq(zoraNFTBase.balanceOf(address(456)), mintQuantity); + assertEq(zoraNFTBase.balanceOf(DEFAULT_FUNDS_RECIPIENT_ADDRESS), totalRoyaltyMintsForPurchase); + + vm.stopPrank(); + } + + function test_SupplyRoyaltyCleanNumbers() public setupZoraNFTBase(100) { + vm.startPrank(DEFAULT_OWNER_ADDRESS); + + zoraNFTBase.updateRoyaltyMintSchedule(5); + + zoraNFTBase.setSaleConfiguration({ + publicSaleStart: 0, + publicSaleEnd: type(uint64).max, + presaleStart: 0, + presaleEnd: 0, + publicSalePrice: 0.1 ether, + maxSalePurchasePerAddress: 100, + presaleMerkleRoot: bytes32(0) + }); + vm.stopPrank(); + + (, uint256 zoraFee) = zoraNFTBase.zoraFeeForAmount(80); + uint256 paymentAmount = 0.1 ether * 80 + zoraFee; + vm.deal(address(456), paymentAmount); + + vm.startPrank(address(456)); + zoraNFTBase.purchase{value: paymentAmount}(80); + + assertEq(zoraNFTBase.balanceOf(address(456)), 80); + assertEq(zoraNFTBase.balanceOf(DEFAULT_FUNDS_RECIPIENT_ADDRESS), 20); + + vm.stopPrank(); + } + + function test_SupplyRoyaltyEdgeCaseNumbers() public setupZoraNFTBase(137) { + vm.startPrank(DEFAULT_OWNER_ADDRESS); + + zoraNFTBase.updateRoyaltyMintSchedule(3); + + zoraNFTBase.setSaleConfiguration({ + publicSaleStart: 0, + publicSaleEnd: type(uint64).max, + presaleStart: 0, + presaleEnd: 0, + publicSalePrice: 0.1 ether, + maxSalePurchasePerAddress: 92, + presaleMerkleRoot: bytes32(0) + }); + vm.stopPrank(); + + (, uint256 zoraFee) = zoraNFTBase.zoraFeeForAmount(92); + uint256 paymentAmount = 0.1 ether * 92 + zoraFee; + vm.deal(address(456), paymentAmount); + + vm.startPrank(address(456)); + zoraNFTBase.purchase{value: paymentAmount}(92); + + assertEq(zoraNFTBase.balanceOf(address(456)), 92); + assertEq(zoraNFTBase.balanceOf(DEFAULT_FUNDS_RECIPIENT_ADDRESS), 45); + + vm.stopPrank(); + } + + function test_SupplyRoyaltyEdgeCaseNumbersOpenEdition() public setupZoraNFTBase(type(uint64).max) { + vm.startPrank(DEFAULT_OWNER_ADDRESS); + + zoraNFTBase.updateRoyaltyMintSchedule(3); + + zoraNFTBase.setSaleConfiguration({ + publicSaleStart: 0, + publicSaleEnd: type(uint64).max, + presaleStart: 0, + presaleEnd: 0, + publicSalePrice: 0.1 ether, + maxSalePurchasePerAddress: 93, + presaleMerkleRoot: bytes32(0) + }); + vm.stopPrank(); + + (, uint256 zoraFee) = zoraNFTBase.zoraFeeForAmount(92); + uint256 paymentAmount = 0.1 ether * 92 + zoraFee; + vm.deal(address(456), paymentAmount); + + vm.startPrank(address(456)); + zoraNFTBase.purchase{value: paymentAmount}(92); + + assertEq(zoraNFTBase.balanceOf(address(456)), 92); + assertEq(zoraNFTBase.balanceOf(DEFAULT_FUNDS_RECIPIENT_ADDRESS), 46); + + (, zoraFee) = zoraNFTBase.zoraFeeForAmount(1); + paymentAmount = 0.1 ether + zoraFee; + vm.deal(address(456), paymentAmount); + + zoraNFTBase.purchase{value: paymentAmount}(1); + + assertEq(zoraNFTBase.balanceOf(address(456)), 93); + assertEq(zoraNFTBase.balanceOf(DEFAULT_FUNDS_RECIPIENT_ADDRESS), 46); + + vm.stopPrank(); + } + function test_EIP165() public view { require(zoraNFTBase.supportsInterface(0x01ffc9a7), "supports 165"); require(zoraNFTBase.supportsInterface(0x80ac58cd), "supports 721");