diff --git a/contracts/extensions/base/LimitedSupply.sol b/contracts/extensions/base/LimitedSupply.sol index 7001a8c1..e8b57858 100644 --- a/contracts/extensions/base/LimitedSupply.sol +++ b/contracts/extensions/base/LimitedSupply.sol @@ -8,7 +8,7 @@ import "../../interfaces/IERC721Community.sol"; abstract contract LimitedSupply is INFTExtension { - uint256 private totalMinted; + uint256 public totalMinted; uint256 public immutable extensionSupply; constructor(uint256 _extensionSupply) { diff --git a/contracts/extensions/allowlist-factory/Allowlist.sol b/contracts/extensions/factories/Allowlist.sol similarity index 100% rename from contracts/extensions/allowlist-factory/Allowlist.sol rename to contracts/extensions/factories/Allowlist.sol diff --git a/contracts/extensions/factories/LimitedSupplyExtension.sol b/contracts/extensions/factories/LimitedSupplyExtension.sol new file mode 100644 index 00000000..bd77b586 --- /dev/null +++ b/contracts/extensions/factories/LimitedSupplyExtension.sol @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.9; + +import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; + +import "./base/NFTExtensionUpgradeable.sol"; +import "./base/SaleControlUpgradeable.sol"; +import "./base/LimitedSupplyUpgradeable.sol"; + +interface NFT is IERC721Community { + function maxSupply() external view returns (uint256); + + function totalSupply() external view returns (uint256); +} + +contract LimitedSupplyExtension is + NFTExtensionUpgradeable, + OwnableUpgradeable, + SaleControlUpgradeable, + LimitedSupplyUpgradeable +{ + uint256 public price; + uint256 public maxPerMint; + uint256 public maxPerWallet; + + string public title; + + /// @custom:oz-upgrades-unsafe-allow constructor + constructor() initializer {} + + function initialize( + string memory _title, + address _nft, + uint256 _price, + uint256 _maxPerMint, + uint256 _maxPerWallet, + uint256 _extensionSupply + ) public initializer { + NFTExtensionUpgradeable.initialize(_nft); + SaleControlUpgradeable.initialize(); + LimitedSupplyUpgradeable.initialize(_extensionSupply); + + title = _title; + price = _price; + maxPerMint = _maxPerMint; + maxPerWallet = _maxPerWallet; + } + + function mint(uint256 amount) + external + payable + whenSaleStarted + whenLimitedSupplyNotReached(amount) + { + require( + IERC721(address(nft)).balanceOf(msg.sender) + amount <= + maxPerWallet, + "LimitedSupplyMintingExtension: max per wallet reached" + ); + + require(amount <= maxPerMint, "Too many tokens to mint"); + require(msg.value >= amount * price, "Not enough ETH to mint"); + + nft.mintExternal{value: msg.value}(amount, msg.sender, bytes32(0x0)); + } + + function maxSupply() public view returns (uint256) { + return NFT(address(nft)).maxSupply(); + } + + function totalSupply() public view returns (uint256) { + return NFT(address(nft)).totalSupply(); + } +} diff --git a/contracts/extensions/factories/README.md b/contracts/extensions/factories/README.md new file mode 100644 index 00000000..84e819fd --- /dev/null +++ b/contracts/extensions/factories/README.md @@ -0,0 +1,126 @@ +## NFT Extension Factories + +update this README according to this block: +- factories deploy minting extensions +- `base` folder has extensions building blocks, you inherit them +- tell about Allowlist and LimitedSupplyExtension +- how to check extensions match their non-upgradeable versions using `colordiff` + +## How it works + +Factory is a contract that deploys extensions. It has two functions: + +- `createExtension` deploys an extension and returns its address +- `createExtensionAndCall` deploys an extension and calls a function on it + +Both functions have the same signature: + +```solidity +function createExtensionAndCall( + address _implementation, + bytes memory _data +) public returns (address extension); +``` + +`_implementation` is the address of the extension implementation. `_data` is the data that is passed to the extension's constructor. It contains the parameters of the extension. + +## Extensions + +### AllowlistExtension + +This extension allows to set an allowlist of addresses that can mint NFTs. It also allows to set a maximum number of NFTs that can be minted. + +### LimitedSupplyExtension + +This extension allows to set a maximum number of NFTs that can be minted. + +## Base Extensions + +LimitedSupply, SaleControl + +### LimitedSupply + +This extension allows to set a maximum number of NFTs that can be minted. + +### SaleControl + +This extension allows to start and stop sale for NFTs. + + +## How to move from non-upgradeable to upgradeable extensions + +Extensions are not really upgradeable, it follows OpenZeppelin terminology. What this means it that they're ready to be used as proxies. + +To move from non-upgradeable to upgradeable extensions, you need to: +- deploy a factory +- deploy an implementation +- deploy an extension using the factory and the implementation + +### Example + +Let's say you have a non-upgradeable extension that looks like this: + +```solidity +contract MintOnlyExtension is NFTExtension { + address public minter; + + constructor(address _minter) { + minter = _minter; + } + + function mint(address _to, uint256 _tokenId) external { + require(msg.sender == minter, "MintOnlyExtension: not a minter"); + _mint(_to, _tokenId); + } +} +``` + +To move to upgradeable extensions, you need to: + +- deploy a factory + +```solidity + +contract MintOnlyExtensionFactory is NFTExtensionFactory { + function createExtension( + address nft, + // ... other parameters + ) public override returns (address extension) { + extension = Clones.clone(_implementation); + extension.initialize(/* ... */); + } +} +``` + +- create upgradeable version of extension + +```solidity +contract MintOnlyExtension is NFTExtension { + address public minter; + + constructor() initializer {} + + function initialize(address _minter) external initializer { + minter = _minter; + } + + function mint(address _to, uint256 _tokenId) external { + require(msg.sender == minter, "MintOnlyExtension: not a minter"); + _mint(_to, _tokenId); + } +} +``` + +- check that the upgradeable version is the same as the non-upgradeable one + +```bash +colordiff contracts/extensions/LimitedSupplyMintingExtension.sol contracts/extensions/factories/LimitedSupplyExtension.sol --context=1 | less +``` + +- deploy the upgradeable version in the factory constructor + +```solidity +constructor() { + _implementation = address(new MintOnlyExtension()); +} +``` diff --git a/contracts/extensions/factories/base/LimitedSupplyUpgradeable.sol b/contracts/extensions/factories/base/LimitedSupplyUpgradeable.sol new file mode 100644 index 00000000..60afa5fd --- /dev/null +++ b/contracts/extensions/factories/base/LimitedSupplyUpgradeable.sol @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.9; + +import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; + +abstract contract LimitedSupplyUpgradeable is OwnableUpgradeable { + + uint256 public totalMinted; + uint256 public extensionSupply; + + function initialize(uint256 _extensionSupply) internal onlyInitializing { + __Ownable_init(); + + extensionSupply = _extensionSupply; + } + + modifier whenLimitedSupplyNotReached(uint256 amount) { + require( + amount + totalMinted <= extensionSupply, + "LimitedSupplyUpgradeable: max extensionSupply reached" + ); + + totalMinted += amount; + + _; + } + +} diff --git a/contracts/extensions/allowlist-factory/base/NFTExtensionUpgradeable.sol b/contracts/extensions/factories/base/NFTExtensionUpgradeable.sol similarity index 100% rename from contracts/extensions/allowlist-factory/base/NFTExtensionUpgradeable.sol rename to contracts/extensions/factories/base/NFTExtensionUpgradeable.sol diff --git a/contracts/extensions/allowlist-factory/base/SaleControlUpgradeable.sol b/contracts/extensions/factories/base/SaleControlUpgradeable.sol similarity index 100% rename from contracts/extensions/allowlist-factory/base/SaleControlUpgradeable.sol rename to contracts/extensions/factories/base/SaleControlUpgradeable.sol diff --git a/contracts/extensions/allowlist-factory/AllowlistFactory.sol b/contracts/extensions/factories/factory/AllowlistFactory.sol similarity index 97% rename from contracts/extensions/allowlist-factory/AllowlistFactory.sol rename to contracts/extensions/factories/factory/AllowlistFactory.sol index e0f0aad9..9443803a 100644 --- a/contracts/extensions/allowlist-factory/AllowlistFactory.sol +++ b/contracts/extensions/factories/factory/AllowlistFactory.sol @@ -3,7 +3,7 @@ pragma solidity ^0.8.4; import "@openzeppelin/contracts/proxy/Clones.sol"; -import "./Allowlist.sol"; +import "../Allowlist.sol"; contract AllowlistFactory { diff --git a/contracts/extensions/factories/factory/LimitedSupplyExtensionFactory.sol b/contracts/extensions/factories/factory/LimitedSupplyExtensionFactory.sol new file mode 100644 index 00000000..471c9716 --- /dev/null +++ b/contracts/extensions/factories/factory/LimitedSupplyExtensionFactory.sol @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.4; + +import "@openzeppelin/contracts/proxy/Clones.sol"; + +import "../LimitedSupplyExtension.sol"; + +// contract by buildship.xyz + +contract LimitedSupplyExtensionFactory { + + event ContractDeployed( + address indexed deployedAddress, + address indexed nft, + address indexed owner, + string title + ); + + address public immutable implementation; + + constructor() { + implementation = address(new LimitedSupplyExtension()); + } + + function createExtension( + string memory title, + address nft, + uint256 price, + uint256 maxPerMint, + uint256 maxPerWallet, + uint256 extensionSupply, + bool startSale + ) external returns (address) { + + address payable clone = payable(Clones.clone(implementation)); + + LimitedSupplyExtension list = LimitedSupplyExtension(clone); + + list.initialize(title, nft, price, maxPerMint, maxPerWallet, extensionSupply); + + if (startSale) { + list.startSale(); + } + + list.transferOwnership(msg.sender); + + emit ContractDeployed(clone, nft, msg.sender, title); + + return clone; + + } +} diff --git a/test/limited-supply-extension.ts b/test/limited-supply-extension.ts new file mode 100644 index 00000000..59d4321d --- /dev/null +++ b/test/limited-supply-extension.ts @@ -0,0 +1,193 @@ +import { expect } from "chai"; +import { ethers } from "hardhat"; +import { LimitedSupplyExtensionFactory } from "../typechain-types"; + +const { parseEther } = ethers.utils; + +describe("LimitedSupplyExtension Factory", () => { + let factory: LimitedSupplyExtensionFactory; + + beforeEach(async () => { + const f = await ethers.getContractFactory("LimitedSupplyExtensionFactory") + + factory = await f.deploy(); + }); + + it("should deploy factory", async () => { + expect(factory.address).to.be.a("string"); + }) + + it("should deploy contract", async function () { + const [owner, user1, user2] = await ethers.getSigners(); + + const nftAddress = user1.address; // not real nft + + const tx = await factory.connect(user2).createExtension( + "Test List", + nftAddress, + parseEther("0.1"), + 10, // max per mint + 10, // max per wallet + 100, // series size + true, // start sale + ); + + const res = await tx.wait(); + + const event = res.events?.find(e => e.event === "ContractDeployed") + + expect(event).to.exist; + + expect(event?.args?.nft).to.equal(nftAddress); + + const contract = await ethers.getContractAt( + "LimitedSupplyExtension", + event?.args?.deployedAddress, + ); + + expect(event?.args?.title).to.equal("Test List"); + expect(contract.address).to.equal(event?.args?.deployedAddress); + + expect(await contract.owner()).to.equal(user2.address); + expect(await contract.nft()).to.equal(nftAddress); + + expect(await contract.saleStarted()).to.equal(true); + + await contract.connect(user2).startSale(); + + expect(await contract.saleStarted()).to.equal(true); + }); + + // it should mint successfully + xit("should check proof validity", async function () { + + const [owner, user1, user2] = await ethers.getSigners(); + + const nftAddress = user1.address; + + const tx = await factory.createExtension( + "Test List", + nftAddress, + parseEther("0"), + 10, // max per mint + 10, // max per wallet + 100, // series size + false, + ); + + const res = await tx.wait(); + + const event = res.events?.find(e => e.event === "ContractDeployed") + + const contract = await ethers.getContractAt( + "LimitedSupplyExtension", + event?.args?.deployedAddress, + ); + + }); + + it("should have title for each allowlist", async function () { + const [owner, user1, user2] = await ethers.getSigners(); + + const nftAddress = user1.address; + + const tx = await factory.createExtension( + "Test List", + nftAddress, + parseEther("0"), + 10, // max per mint + 10, // max per wallet + 100, // series size + false, + ); + + const res = await tx.wait(); + + const event = res.events?.find(e => e.event === "ContractDeployed") + + const contract = await ethers.getContractAt( + "LimitedSupplyExtension", + event?.args?.deployedAddress, + ); + + expect(await contract.title()).to.equal("Test List"); + }); + + // // it should mint successfully + it("should mint successfully", async function () { + const NFT = await ethers.getContractFactory("ERC721CommunityBase"); + + const [ minter1, minter2, minter3 ] = await ethers.getSigners(); + + const root = "0xfbc2f54de92972c0f2c6bbd5003031662aa9b8240f4375dc03d3157d8651ec45" + + const proof1 = [ + "0x343750465941b29921f50a28e0e43050e5e1c2611a3ea8d7fe1001090d5e1436" + ] + const proof2 = [ + "0x8a3552d60a98e0ade765adddad0a2e420ca9b1eef5f326ba7ab860bb4ea72c94", + "0xe9707d0e6171f728f7473c24cc0432a9b07eaaf1efed6a137a4a8c12c79552d9" + ] + + const proof3 = [ + "0x00314e565e0574cb412563df634608d76f5c59d9f817e85966100ec1d48005c0", + "0xe9707d0e6171f728f7473c24cc0432a9b07eaaf1efed6a137a4a8c12c79552d9" + ] + + const nft1 = await NFT.deploy( + "Test NFT", + "TEST", + 100, + 1, + false, + "https://example.com", + { + publicPrice: parseEther("0.1"), + maxTokensPerMint: 5, + maxTokensPerWallet: 5, + royaltyFee: 500, + payoutReceiver: "0x0000000000000000000000000000000000000000", + shouldLockPayoutReceiver: false, + shouldStartSale: false, + shouldUseJsonExtension: false, + } + ); + + const tx = await factory.createExtension( + "Test List", + nft1.address, + parseEther("0"), + 10, // max per mint + 10, // max per wallet + 100, // series size + true, // start sale + ); + + const res = await tx.wait(); + const event = res.events?.find(e => e.event === "ContractDeployed") + + const list = await ethers.getContractAt( + "LimitedSupplyExtension", + event?.args?.deployedAddress, + ); + + expect(await list.maxSupply()).to.equal(await nft1.maxSupply()); + + await nft1.addExtension(list.address); + + await list.connect(minter1).mint(1); + + expect(await nft1.balanceOf(minter1.address)).to.equal(1); + + await list.connect(minter2).mint(1); + + expect(await nft1.balanceOf(minter2.address)).to.equal(1); + + await list.connect(minter3).mint(1); + + expect(await nft1.balanceOf(minter3.address)).to.equal(1); + + }); + + +});