diff --git a/contracts/extensions/DutchAuction.sol b/contracts/extensions/DutchAuction.sol new file mode 100644 index 00000000..4cb68cb3 --- /dev/null +++ b/contracts/extensions/DutchAuction.sol @@ -0,0 +1,118 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.9; + +import "@openzeppelin/contracts/proxy/Clones.sol"; + +import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/security/PausableUpgradeable.sol"; + +import "./allowlist-factory/base/NFTExtensionUpgradeable.sol"; +import "./allowlist-factory/base/SaleControlUpgradeable.sol"; + +import "./base/NFTExtension.sol"; +import "./base/SaleControl.sol"; + +contract DutchAuctionFactory { + DutchAuction public implementation; + + constructor() { + implementation = new DutchAuction(); + } + + function createExtension( + address _nft, + uint256 _price, + uint256 _maxPerAddress, + uint256 _startTimestamp, + uint256 _endTimestamp + ) public returns (DutchAuction) { + address clone = Clones.clone(address(implementation)); + + DutchAuction(clone).initialize( + _nft, + _price, + _maxPerAddress, + _startTimestamp, + _endTimestamp, + msg.sender + ); + + // if (startSale) { + // DutchAuction(clone).startSale(); + // } + + // DutchAuction(clone).transferOwnership(msg.sender); + + return DutchAuction(clone); + } +} + +contract DutchAuction is + NFTExtensionUpgradeable, + OwnableUpgradeable + // SaleControlUpgradeable +{ + uint256 public startingPrice; + uint256 public startTimestamp; + uint256 public endTimestamp; + uint256 public maxPerAddress; + + mapping(address => uint256) public claimedByAddress; + + /// @custom:oz-upgrades-unsafe-allow constructor + constructor() initializer {} + + function initialize( + address _nft, + uint256 _price, + uint256 _maxPerAddress, + uint256 _startTimestamp, + uint256 _endTimestamp, + address owner + ) public initializer { + NFTExtensionUpgradeable.initialize(_nft); + // SaleControlUpgradeable.initialize(); + __Ownable_init(); + transferOwnership(owner); + + startingPrice = _price; + maxPerAddress = _maxPerAddress; + startTimestamp = _startTimestamp; + endTimestamp = _endTimestamp; + + } + + function updatePrice(uint256 _price) public onlyOwner { + startingPrice = _price; + } + + function updateMaxPerAddress(uint256 _maxPerAddress) public onlyOwner { + maxPerAddress = _maxPerAddress; + } + + function updateEndTimestamp(uint256 _timestamp) public onlyOwner { + endTimestamp = _timestamp; + } + + function mint(uint256 nTokens) external payable { + require(block.timestamp <= endTimestamp, "Sale has ended"); + require(block.timestamp >= startTimestamp, "Sale has not started"); + + require(nTokens <= maxPerAddress, "Cannot claim more per transaction"); + + require(msg.value >= nTokens * price(), "Not enough ETH to mint"); + + nft.mintExternal{value: msg.value}(nTokens, msg.sender, bytes32(0x0)); + + // TODO: refund unused? + } + + function price() public view returns (uint256 currentPrice) { + // start at startTimestamp at startingPrice, gradually falls at reducePriceSpeed per second + // currentPrice = startingPrice - (block.timestamp - startTimestamp) * + currentPrice = + startingPrice - + (block.timestamp - startTimestamp) * + (endTimestamp - startTimestamp); + } +} diff --git a/contracts/extensions/DutchAuctionExtension.sol b/contracts/extensions/DutchAuctionExtension.sol new file mode 100644 index 00000000..fe1dbf45 --- /dev/null +++ b/contracts/extensions/DutchAuctionExtension.sol @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.9; + +import "@openzeppelin/contracts/access/Ownable.sol"; +import "@openzeppelin/contracts/security/Pausable.sol"; +import "@openzeppelin/contracts/utils/cryptography/MerkleProof.sol"; + +import "./base/NFTExtension.sol"; +import "./base/SaleControl.sol"; + +contract DutchAuctionExtension is NFTExtension, Ownable, SaleControl { + + uint256 public startingPrice; + uint256 public endTimestamp; + uint256 public maxPerAddress; + + mapping (address => uint256) public claimedByAddress; + + constructor(address _nft, uint256 _price, uint256 _endTimestamp) NFTExtension(_nft) SaleControl() { + stopSale(); + + startingPrice = _price; + endTimestamp = _endTimestamp; + } + + function updatePrice(uint256 _price) public onlyOwner { + startingPrice = _price; + } + + function updateMaxPerAddress(uint256 _maxPerAddress) public onlyOwner { + maxPerAddress = _maxPerAddress; + } + + function updateEndTimestamp(uint256 _timestamp) public onlyOwner { + endTimestamp = _timestamp; + } + + function mint(uint256 nTokens) external whenSaleStarted payable { + + require(nTokens <= maxPerAddress, "Cannot claim more per transaction"); + + require(msg.value >= nTokens * price(), "Not enough ETH to mint"); + + nft.mintExternal{ value: msg.value }(nTokens, msg.sender, bytes32(0x0)); + + // TODO: refund unused? + + } + + + function price() public view returns (uint256 currentPrice) { + // start at startTimestamp at startingPrice, gradually falls at reducePriceSpeed per second + // currentPrice = startingPrice - (block.timestamp - startTimestamp) * + currentPrice = startingPrice - (block.timestamp - startTimestamp) * (endTimestamp - startTimestamp); + } + +} diff --git a/contracts/extensions/DutchAuctionExtensionSingleton.sol b/contracts/extensions/DutchAuctionExtensionSingleton.sol new file mode 100644 index 00000000..9cfdd03b --- /dev/null +++ b/contracts/extensions/DutchAuctionExtensionSingleton.sol @@ -0,0 +1,161 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.9; + +import "@openzeppelin/contracts/access/Ownable.sol"; +import "@openzeppelin/contracts/security/Pausable.sol"; +import "@openzeppelin/contracts/utils/cryptography/MerkleProof.sol"; + +import "./base/NFTExtension.sol"; +// import "./base/SaleControl.sol"; + +uint256 constant __SALE_NEVER_STARTS = 2 ** 256 - 1; + +contract DutchAuctionExtensionSingleton { + modifier onlyNFTOwner(IERC721Community nft) { + require( + Ownable(address(nft)).owner() == msg.sender, + "MintBatchExtension: Not NFT owner" + ); + _; + } + + mapping(IERC721Community => uint256) public startPrice; + mapping(IERC721Community => uint256) public endPrice; + + mapping(IERC721Community => uint256) public startTimestamp; + mapping(IERC721Community => uint256) public endTimestamp; + + mapping(IERC721Community => uint256) public maxPerAddress; + + mapping(IERC721Community => mapping(address => uint256)) + public claimedByAddress; + + constructor() {} + + function configureSale( + IERC721Community collection, + uint256 _startPrice, + uint256 _endPrice, + uint256 _startTimestamp, + uint256 _endTimestamp, + uint256 _maxPerAddress + ) public onlyNFTOwner(collection) returns (address) { + require( + _startTimestamp >= block.timestamp, + "Start time must be in the future" + ); + require( + _endTimestamp > _startTimestamp, + "End time must be after start time" + ); + + require( + _startPrice > _endPrice, + "Start price must be greater than end price" + ); + + startTimestamp[collection] = _startTimestamp; + endTimestamp[collection] = _endTimestamp; + + startPrice[collection] = _startPrice; + endPrice[collection] = _endPrice; + + maxPerAddress[collection] = _maxPerAddress; + + return address(this); + } + + function updatePrice( + IERC721Community collection, + uint256 _price + ) public onlyNFTOwner(collection) { + startPrice[collection] = _price; + } + + function updateMaxPerAddress( + IERC721Community collection, + uint256 _maxPerAddress + ) public onlyNFTOwner(collection) { + maxPerAddress[collection] = _maxPerAddress; + } + + function updateEndTimestamp( + IERC721Community collection, + uint256 _timestamp + ) public onlyNFTOwner(collection) { + endTimestamp[collection] = _timestamp; + } + + function startSale( + IERC721Community collection + ) public onlyNFTOwner(collection) { + startTimestamp[collection] = block.timestamp; + endTimestamp[collection] = __SALE_NEVER_STARTS; + } + + function stopSale( + IERC721Community collection + ) public onlyNFTOwner(collection) { + startTimestamp[collection] = __SALE_NEVER_STARTS; + endTimestamp[collection] = __SALE_NEVER_STARTS; + } + + // function saleStarted(IERC721Community collection) public view returns (bool) { + // return block.timestamp >= startTimestamp[collection] && block.timestamp <= endTimestamp[collection]; + // } + + function mint( + IERC721Community collection, + uint256 nTokens + ) external payable { + require( + block.timestamp >= startTimestamp[collection], + "Sale not started" + ); + require(block.timestamp <= endTimestamp[collection], "Sale ended"); + + require( + nTokens + claimedByAddress[collection][msg.sender] <= + maxPerAddress[collection], + "Cannot claim more than maxPerAddress" + ); + + require( + msg.value >= nTokens * price(collection), + "Not enough ETH to mint" + ); + + collection.mintExternal{value: msg.value}( + nTokens, + msg.sender, + bytes32(0x0) + ); + + // TODO: refund unused? + } + + function price( + IERC721Community collection + ) public view returns (uint256 currentPrice) { + // start at startTimestamp at startPrice, gradually falls until endTimestamp at endPrice + + if (block.timestamp <= startTimestamp[collection]) { + return startPrice[collection]; + } + + if (block.timestamp >= endTimestamp[collection]) { + return endPrice[collection]; + } + + uint256 timeDelta = endTimestamp[collection] - + startTimestamp[collection]; + + uint256 priceDelta = startPrice[collection] - endPrice[collection]; + + uint256 timeElapsed = block.timestamp - startTimestamp[collection]; + + uint256 priceChange = (timeElapsed * priceDelta) / timeDelta; + + return startPrice[collection] - priceChange; + } +} diff --git a/contracts/standards/ERC721CommunityBase.sol b/contracts/standards/ERC721CommunityBase.sol index 5d8e29a3..9b89a533 100644 --- a/contracts/standards/ERC721CommunityBase.sol +++ b/contracts/standards/ERC721CommunityBase.sol @@ -11,6 +11,8 @@ import "erc721a/contracts/ERC721A.sol"; import "@openzeppelin/contracts/access/Ownable.sol"; import "@openzeppelin/contracts/security/ReentrancyGuard.sol"; +import "@openzeppelin/contracts/proxy/Clones.sol"; + import "@openzeppelin/contracts/utils/Address.sol"; import "@openzeppelin/contracts/utils/introspection/ERC165Checker.sol"; @@ -282,6 +284,25 @@ contract ERC721CommunityBase is return extensions.length; } + function deployExtension( + address implementation, + bytes memory initData + ) public onlyOwner returns (address extension) { + require(implementation != address(0), "Extension implementation cannot be zero address"); + + extension = Clones.clone(implementation); + + // call extension.initialize encoded with selector and data + + (bool success, ) = extension.call(initData); + + require(success, "Extension initialization failed"); + + // TODO: transfer ownership? + + addExtension(extension); + } + // Extensions are allowed to mint function addExtension(address _extension) public onlyOwner { require(_extension != address(this), "Cannot add self as extension"); diff --git a/test/foundry/DutchAuction.t.sol b/test/foundry/DutchAuction.t.sol new file mode 100644 index 00000000..cbb70fc0 --- /dev/null +++ b/test/foundry/DutchAuction.t.sol @@ -0,0 +1,251 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.0; + +import "forge-std/Test.sol"; + +import "contracts/extensions/DutchAuctionExtension.sol"; +import "contracts/extensions/DutchAuctionExtensionSingleton.sol"; +import "contracts/extensions/DutchAuction.sol"; + +import "contracts/standards/ERC721CommunityBase.sol"; + +contract DutchAuctionTest is Test { + DutchAuctionExtensionSingleton dutchAuctionExtensionSingleton; + DutchAuctionExtension dutchAuctionExtension; + + DutchAuction dutchAuction; + + DutchAuctionFactory dutchAuctionFactory; + + IERC721Community nft; + + address admin; + + uint amount = 1; + + function setUp() public { + admin = makeAddr("Admin"); + vm.deal(admin, 100 ether); + vm.startPrank(admin); + + nft = new ERC721CommunityBase( + "Test", + "NFT", + 10000, + 15, // reserved + false, + "ipfs://factory-test/", + MintConfig(0.03 ether, 20, 20, 500, msg.sender, false, false, false) + ); + + dutchAuctionFactory = new DutchAuctionFactory(); + + dutchAuctionExtensionSingleton = new DutchAuctionExtensionSingleton(); + } + + function OFFtestSingleton() public { + amount = 1; // bound(amount, 1, 100); + + nft.addExtension(address(dutchAuctionExtensionSingleton)); + + dutchAuctionExtensionSingleton.startSale(nft); + dutchAuctionExtensionSingleton.updatePrice(nft, 100); + dutchAuctionExtensionSingleton.updateMaxPerAddress(nft, 200); + dutchAuctionExtensionSingleton.updateEndTimestamp( + nft, + block.timestamp + 1000 + ); + + assertEq(dutchAuctionExtensionSingleton.startPrice(nft), 100); + assertEq(dutchAuctionExtensionSingleton.maxPerAddress(nft), 200); + assertEq( + dutchAuctionExtensionSingleton.endTimestamp(nft), + block.timestamp + 1000 + ); + // assertTrue(dutchAuctionExtensionSingleton.saleStarted(nft)); + + // test mint gas cost + uint256 gasCost = gasleft(); + + dutchAuctionExtensionSingleton.mint{value: 100 * amount}(nft, amount); + + gasCost = gasCost - gasleft(); + + console.log("Gas cost per mint singleton: ", gasCost / amount); + } + + function OFFtestExtension() public { + amount = 1; // bound(amount, 1, 100); + + dutchAuctionExtension = new DutchAuctionExtension( + (address(nft)), + 100, + 10_000_000_000 + ); + + nft.addExtension(address(dutchAuctionExtension)); + dutchAuctionExtension.startSale(); + + dutchAuctionExtension.updatePrice(100); + dutchAuctionExtension.updateMaxPerAddress(200); + dutchAuctionExtension.updateEndTimestamp(block.timestamp + 1000); + + assertEq(dutchAuctionExtension.startingPrice(), 100); + assertEq(dutchAuctionExtension.maxPerAddress(), 200); + assertEq(dutchAuctionExtension.endTimestamp(), block.timestamp + 1000); + assertTrue(dutchAuctionExtension.saleStarted()); + + // test mint gas cost + + uint256 gasCost = gasleft(); + + dutchAuctionExtension.mint{value: 100 * amount}(amount); + + gasCost = gasCost - gasleft(); + + console.log("Gas cost per mint extension: ", gasCost / amount); + } + + function OFFtestCrosscompare(uint amount) public { + amount = bound(amount, 5, 100); + + dutchAuctionExtensionSingleton = new DutchAuctionExtensionSingleton(); + + nft.addExtension(address(dutchAuctionExtensionSingleton)); + + dutchAuctionExtensionSingleton.startSale(nft); + dutchAuctionExtensionSingleton.updatePrice(nft, 100); + dutchAuctionExtensionSingleton.updateMaxPerAddress(nft, 200); + dutchAuctionExtensionSingleton.updateEndTimestamp( + nft, + block.timestamp + 1000 + ); + + assertEq(dutchAuctionExtensionSingleton.startPrice(nft), 100); + assertEq(dutchAuctionExtensionSingleton.maxPerAddress(nft), 200); + assertEq( + dutchAuctionExtensionSingleton.endTimestamp(nft), + block.timestamp + 1000 + ); + // assertTrue(dutchAuctionExtensionSingleton.saleStarted(nft)); + + // test mint gas cost + uint256 gasCost = gasleft(); + + dutchAuctionExtensionSingleton.mint{value: 100 * amount}(nft, amount); + + gasCost = gasCost - gasleft(); + + console.log("Gas cost per mint singleton: ", gasCost / amount); + + dutchAuctionExtension = new DutchAuctionExtension( + (address(nft)), + 100, + 10_000_000_000 + ); + + nft.addExtension(address(dutchAuctionExtension)); + dutchAuctionExtension.startSale(); + + dutchAuctionExtension.updatePrice(100); + dutchAuctionExtension.updateMaxPerAddress(200); + dutchAuctionExtension.updateEndTimestamp(block.timestamp + 1000); + + assertEq(dutchAuctionExtension.startingPrice(), 100); + assertEq(dutchAuctionExtension.maxPerAddress(), 200); + assertEq(dutchAuctionExtension.endTimestamp(), block.timestamp + 1000); + assertTrue(dutchAuctionExtension.saleStarted()); + + // test mint gas cost + + gasCost = gasleft(); + + dutchAuctionExtension.mint{value: 100 * amount}(amount); + + gasCost = gasCost - gasleft(); + + console.log("Gas cost per mint extension: ", gasCost / amount); + + assertTrue(false); + } + + function testDeployExtension() public { + + DutchAuction implementation = new DutchAuction(); + + // we already have nft configured, we need to deploy the extension and add it to the nft + + uint gasCost = gasleft(); + + // dutchAuction = dutchAuctionFactory.createExtension( + // address(nft), + // 100, + // 10, + // block.timestamp, + // block.timestamp + 1000 + // ); + + // nft.addExtension(address(dutchAuction)); + + address ext = ERC721CommunityBase(payable(address(nft))).deployExtension( + address(implementation), + abi.encodeWithSelector( + DutchAuction.initialize.selector, + address(nft), + 100, + 10, + block.timestamp, + block.timestamp + 1000, + admin + ) + ); + + // console.log("Deploy result: ", string(result)); + + gasCost = gasCost - gasleft(); + + console.log("Gas cost to deploy extension: ", gasCost); + + // test mint + + gasCost = gasleft(); + + DutchAuction(ext).mint{value: 100 * amount}(amount); + + gasCost = gasCost - gasleft(); + + console.log("Gas cost to mint via extension: ", gasCost / amount); + } + + function testConfigSingleton() public { + // we already have nft configured, we need to deploy the extension and add it to the nft + + uint gasCost = gasleft(); + + dutchAuctionExtensionSingleton.configureSale( + (nft), + 100, + 10, + block.timestamp, + block.timestamp + 1000, + 5 // max per address + ); + + nft.addExtension(address(dutchAuctionExtensionSingleton)); + + gasCost = gasCost - gasleft(); + + console.log("Gas cost to configure singleton: ", gasCost); + + // test mint + + gasCost = gasleft(); + + dutchAuctionExtensionSingleton.mint{value: 100 * amount}(nft, amount); + + gasCost = gasCost - gasleft(); + + console.log("Gas cost to mint via singleton: ", gasCost / amount); + } +}