diff --git a/contracts/extensions/allowlist-factory/Allowlist.sol b/contracts/extensions/allowlist-factory/Allowlist.sol new file mode 100644 index 00000000..41583483 --- /dev/null +++ b/contracts/extensions/allowlist-factory/Allowlist.sol @@ -0,0 +1,105 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.9; + +// Want to launch your own collection? +// Check out https://buildship.xyz +// +// ,:loxO0KXXc +// ,cdOKKKOxol:lKWl +// ;oOXKko:, ;KNc +// 'ox0X0d: cNK, +// ',' ;xXX0x: dWk +// ,cdO0KKKKKXKo, ,0Nl +// ;oOXKko:,;kWMNl dWO' +// ,o0XKd:' oNMMK: cXX: +// 'ckNNk: ;KMN0c cXXl +// 'OWMMWKOdl;' cl; oXXc +// ;cclldxOKXKkl, ;kNO; +// ;cdk0kl' ;clxXXo +// ':oxo' c0WMMMMK; +// :l: lNMWXxOWWo +// '; :xdc' :XWd +// , cXK; +// ':, xXl +// ;: ' o0c +// ;c;,,,,' lx; +// ''' cc +// ,' + +import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/security/PausableUpgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/utils/cryptography/MerkleProofUpgradeable.sol"; + +import "./base/SaleControlUpgradeable.sol"; + +import "./base/NFTExtensionUpgradeable.sol"; + +contract Allowlist is NFTExtensionUpgradeable, SaleControlUpgradeable { + uint256 public price; + uint256 public maxPerAddress; + + bytes32 public whitelistRoot; + + mapping(address => uint256) public claimedByAddress; + + /// @custom:oz-upgrades-unsafe-allow constructor + constructor() initializer {} + + function initialize( + address _nft, + bytes32 _whitelistRoot, + uint256 _price, + uint256 _maxPerAddress + ) initializer public { + NFTExtensionUpgradeable.initialize(_nft); + SaleControlUpgradeable.initialize(); + + price = _price; + maxPerAddress = _maxPerAddress; + whitelistRoot = _whitelistRoot; + } + + function updatePrice(uint256 _price) public onlyOwner { + price = _price; + } + + function updateMaxPerAddress(uint256 _maxPerAddress) public onlyOwner { + maxPerAddress = _maxPerAddress; + } + + function updateWhitelistRoot(bytes32 _whitelistRoot) public onlyOwner { + whitelistRoot = _whitelistRoot; + } + + function mint(uint256 nTokens, bytes32[] memory proof) + external + payable + whenSaleStarted + { + require( + isWhitelisted(whitelistRoot, msg.sender, proof), + "Not whitelisted" + ); + + require( + claimedByAddress[msg.sender] + nTokens <= maxPerAddress, + "Cannot claim more per address" + ); + + require(msg.value >= nTokens * price, "Not enough ETH to mint"); + + claimedByAddress[msg.sender] += nTokens; + + nft.mintExternal{value: msg.value}(nTokens, msg.sender, bytes32(0x0)); + } + + function isWhitelisted( + bytes32 root, + address receiver, + bytes32[] memory proof + ) public pure returns (bool) { + bytes32 leaf = keccak256(abi.encodePacked(receiver)); + + return MerkleProofUpgradeable.verify(proof, root, leaf); + } +} diff --git a/contracts/extensions/allowlist-factory/AllowlistFactory.sol b/contracts/extensions/allowlist-factory/AllowlistFactory.sol new file mode 100644 index 00000000..f538fb14 --- /dev/null +++ b/contracts/extensions/allowlist-factory/AllowlistFactory.sol @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.4; + +import "@openzeppelin/contracts/proxy/Clones.sol"; + +import "./Allowlist.sol"; + +contract AllowlistFactory { + + event ContractDeployed( + address indexed deployedAddress, + address indexed nft, + address indexed owner, + string title + ); + + address public immutable implementation; + + constructor() { + implementation = address(new Allowlist()); + } + + function createAllowlist( + string memory title, + address nft, + bytes32 root, + uint256 price, + uint256 maxPerAddress, + bool startSale + ) external returns (address) { + + address payable clone = payable(Clones.clone(implementation)); + + Allowlist list = Allowlist(clone); + + list.initialize(nft, root, price, maxPerAddress); + + if (startSale) { + list.startSale(); + } + + list.transferOwnership(msg.sender); + + emit ContractDeployed(clone, nft, msg.sender, title); + + return clone; + + } +} diff --git a/contracts/extensions/allowlist-factory/base/NFTExtensionUpgradeable.sol b/contracts/extensions/allowlist-factory/base/NFTExtensionUpgradeable.sol new file mode 100644 index 00000000..c873c376 --- /dev/null +++ b/contracts/extensions/allowlist-factory/base/NFTExtensionUpgradeable.sol @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.9; + +import "@openzeppelin/contracts-upgradeable/utils/introspection/ERC165Upgradeable.sol"; + +import "../../../interfaces/INFTExtension.sol"; +import "../../../interfaces/IMetaverseNFT.sol"; + +contract NFTExtensionUpgradeable is INFTExtension, ERC165Upgradeable { + IMetaverseNFT public nft; + + function initialize(address _nft) internal onlyInitializing { + __ERC165_init(); + + nft = IMetaverseNFT(_nft); + } + + function beforeMint() internal view { + require( + nft.isExtensionAdded(address(this)), + "NFTExtension: this contract is not allowed to be used as an extension" + ); + } + + function supportsInterface(bytes4 interfaceId) + public + view + virtual + override(IERC165, ERC165Upgradeable) + returns (bool) + { + return + interfaceId == type(INFTExtension).interfaceId || + super.supportsInterface(interfaceId); + } +} diff --git a/contracts/extensions/allowlist-factory/base/SaleControlUpgradeable.sol b/contracts/extensions/allowlist-factory/base/SaleControlUpgradeable.sol new file mode 100644 index 00000000..bd6473fb --- /dev/null +++ b/contracts/extensions/allowlist-factory/base/SaleControlUpgradeable.sol @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.9; + +import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; + +abstract contract SaleControlUpgradeable is OwnableUpgradeable { + uint256 public constant __SALE_NEVER_STARTS = 2**256 - 1; + + uint256 public startTimestamp; + + function initialize() internal onlyInitializing { + __Ownable_init(); + + startTimestamp = __SALE_NEVER_STARTS; + } + + modifier whenSaleStarted() { + require(saleStarted(), "Sale not started yet"); + _; + } + + function updateStartTimestamp(uint256 _startTimestamp) public onlyOwner { + startTimestamp = _startTimestamp; + } + + function startSale() public onlyOwner { + startTimestamp = block.timestamp; + } + + function stopSale() public onlyOwner { + startTimestamp = __SALE_NEVER_STARTS; + } + + function saleStarted() public view returns (bool) { + return block.timestamp >= startTimestamp; + } +} diff --git a/test/allowlist.ts b/test/allowlist.ts new file mode 100644 index 00000000..0164f845 --- /dev/null +++ b/test/allowlist.ts @@ -0,0 +1,167 @@ +import { expect } from "chai"; +import { ethers } from "hardhat"; +import { AllowlistFactory } from "../typechain-types"; + +const { parseEther } = ethers.utils; + +describe("Allowlist Factory", () => { + let factory: AllowlistFactory; + + beforeEach(async () => { + const f = await ethers.getContractFactory("AllowlistFactory") + + 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; + + const tx = await factory.connect(user2).createAllowlist( + "Test List", + nftAddress, + "0xbd204967d5ef69fe133d1e2e9509f68bf3ee681006804e37b0bd51a64aea0116", + parseEther("0.1"), + 1, + true, + ); + + 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( + "Allowlist", + 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 + it("should check proof validity", async function () { + const address = "0xffe06cb4807917bd79382981f23d16a70c102c3b" + const root = "0x01a190633cf36eb0a207f16c11b88f873b92aa9c248482ed87bae56fe75c6871" + const proof = [ + "0x8b37442083367ac89eaf201381abfda29d6f9761782890715d67e7945771a467" + ] + + const [owner, user1, user2] = await ethers.getSigners(); + + const nftAddress = user1.address; + + const tx = await factory.createAllowlist( + "Test List", + nftAddress, + root, + parseEther("0"), + 1, + false, + ); + + const res = await tx.wait(); + + const event = res.events?.find(e => e.event === "ContractDeployed") + + const contract = await ethers.getContractAt( + "Allowlist", + event?.args?.deployedAddress, + ); + + expect(await contract.whitelistRoot()).to.equal(root); + expect(await contract.isWhitelisted(root, address, proof)).to.equal(true); + + expect(await contract.isWhitelisted(root, user2.address, proof)).to.equal(false); + + }); + + // // it should mint successfully + it("should mint successfully", async function () { + const NFT = await ethers.getContractFactory("MetaverseBaseNFT"); + + 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( + parseEther("0.01"), + 100, + 1, + 1, + 100, // 1% + "Test NFT", + "TEST", + "https://example.com", + false, + ); + + const tx = await factory.createAllowlist( + "Test List", + nft1.address, + root, + parseEther("0"), + 1, // max per address + true, // start sale + ); + + const res = await tx.wait(); + const event = res.events?.find(e => e.event === "ContractDeployed") + + const list = await ethers.getContractAt( + "Allowlist", + event?.args?.deployedAddress, + ); + + expect(await list.whitelistRoot()).to.equal(root); + expect(await list.isWhitelisted(root, minter1.address, proof1)).to.equal(true); + + await nft1.addExtension(list.address); + + await list.connect(minter1).mint(1, proof1); + + expect(await nft1.balanceOf(minter1.address)).to.equal(1); + + await list.connect(minter2).mint(1, proof2); + + expect(await nft1.balanceOf(minter2.address)).to.equal(1); + + await list.connect(minter3).mint(1, proof3); + + expect(await nft1.balanceOf(minter3.address)).to.equal(1); + + }); + + +}); \ No newline at end of file