├── .gitignore ├── hardhat.config.js ├── contracts ├── NFT.sol └── MDDA.sol ├── readme.md ├── package.json └── test └── MDDA.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .env 3 | 4 | #Hardhat files 5 | cache 6 | artifacts -------------------------------------------------------------------------------- /hardhat.config.js: -------------------------------------------------------------------------------- 1 | require("@nomiclabs/hardhat-waffle"); 2 | require("hardhat-gas-reporter"); 3 | 4 | module.exports = { 5 | solidity: { 6 | version: "0.8.4", 7 | settings: { 8 | optimizer: { 9 | enabled: true, 10 | runs: 200, 11 | }, 12 | }, 13 | }, 14 | networks: { 15 | hardhat: { 16 | blockGasLimit: 29000000, 17 | accounts: { 18 | count: 20 19 | } 20 | } 21 | } 22 | }; -------------------------------------------------------------------------------- /contracts/NFT.sol: -------------------------------------------------------------------------------- 1 | //SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.4; 3 | 4 | import "@openzeppelin/contracts/access/Ownable.sol"; 5 | import "erc721a/contracts/ERC721A.sol"; 6 | import "./MDDA.sol"; 7 | 8 | 9 | contract NFT is Ownable, ERC721A, MDDA { 10 | constructor() 11 | ERC721A("NFT", "NFT") 12 | {} 13 | 14 | function mintDutchAuction(uint8 _quantity) public payable { 15 | DAHook(_quantity, totalSupply()); 16 | 17 | //Mint the quantity 18 | _safeMint(msg.sender, _quantity); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | Fully on chain decentralized dutch auction with decrementing price tiers and a refund system based on ending price 2 | 3 | If used as is, contract is unruggable by an admin. 4 | 5 | Withdraw funds only returns quantity * final price. 6 | 7 | Standard is in contacts/MDDA.sol 8 | 9 | Implementation Example is in contracts/NFT.sol 10 | 11 | CURRENT VERSION IS HIGHLY EXPERIMENTAL AND COULD INCLUDE A MYRIAD OF BREAKING BUGS, NOT TO BE USED WITHOUT EXTREME DISCRECTION. 12 | 13 | Amount of unit tests is currently very low and not very helpful due to me having not done it yet. -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mdda", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "keywords": [], 10 | "author": "", 11 | "license": "ISC", 12 | "devDependencies": { 13 | "@nomiclabs/hardhat-ethers": "^2.0.5", 14 | "@nomiclabs/hardhat-waffle": "^2.0.3", 15 | "chai": "^4.3.6", 16 | "ethereum-waffle": "^3.4.4", 17 | "ethers": "^5.6.2", 18 | "hardhat": "^2.9.2" 19 | }, 20 | "dependencies": { 21 | "@openzeppelin/contracts": "^4.5.0", 22 | "erc721a": "^3.1.0", 23 | "hardhat-gas-reporter": "^1.0.8" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /test/MDDA.js: -------------------------------------------------------------------------------- 1 | const { expect } = require("chai"); 2 | const { ethers, waffle } = require("hardhat"); 3 | const Promise = require("bluebird"); 4 | 5 | let _NFT; 6 | let NFT; 7 | 8 | let owner; 9 | 10 | provider = waffle.provider; 11 | 12 | before(async function () { 13 | _NFT = await ethers.getContractFactory("NFT"); 14 | NFT = await _NFT.deploy(); 15 | 16 | [owner, minter2, minter3] = await ethers.getSigners(); 17 | }); 18 | 19 | const setCurrentBlockTime = async (newTimestamp) => { 20 | await network.provider.send("evm_setNextBlockTimestamp", [newTimestamp]); 21 | await network.provider.send("evm_mine"); 22 | }; 23 | 24 | const sendEth = (amt) => { 25 | return { value: ethers.utils.parseEther(amt) }; 26 | }; 27 | 28 | describe("Tests", function () { 29 | it("Sets DA data", async function () { 30 | await NFT.initializeAuctionData( 31 | ethers.utils.parseEther("0.5"), 32 | ethers.utils.parseEther("0.1"), 33 | ethers.utils.parseEther("0.05"), 34 | 180, 35 | Math.floor(Date.now() / 1000) - 2, 36 | 5, 37 | 7000 38 | ); 39 | }); 40 | 41 | it("Should expect price to be 0.5", async function () { 42 | expect((await NFT.currentPrice()).toString()).to.equal( 43 | "500000000000000000" 44 | ); 45 | }); 46 | 47 | it("Should mint lotsa nfts", async function () { 48 | for (var i = 0; i < 200; i++) { 49 | await NFT.connect(owner).mintDutchAuction(5, sendEth("3")); 50 | } 51 | for (var i = 0; i < 0; i++) { 52 | await NFT.connect(minter2).mintDutchAuction(5, sendEth("3")); 53 | } 54 | 55 | expect(await NFT.totalSupply()).to.equal(5 * 200); 56 | }); 57 | 58 | it("nft should now cost 0.45", async function () { 59 | expect((await NFT.currentPrice()).toString()).to.equal( 60 | "450000000000000000" 61 | ); 62 | }); 63 | 64 | it("Should mint rest", async function () { 65 | for (var i = 0; i < 1200; i++) { 66 | await NFT.connect(minter2).mintDutchAuction(5, sendEth("3")); 67 | } 68 | 69 | expect(await NFT.totalSupply()).to.equal(5 * 200 + 5 * 1200); 70 | }); 71 | 72 | it("Logs ending DA price", async function () { 73 | console.log(await NFT.DA_FINAL_PRICE()); 74 | }); 75 | 76 | it("Refunds itself all da eth", async function () { 77 | console.log({ 78 | "Contract balance": (await provider.getBalance(NFT.address)).toString(), 79 | "User balance": (await provider.getBalance(owner.address)).toString(), 80 | claims: (await NFT.userToTokenBatches(owner.address)).length, 81 | }); 82 | 83 | console.log("Refunding extra eth."); 84 | await NFT.connect(owner).refundExtraETH(); 85 | 86 | console.log({ 87 | "Contract balance": (await provider.getBalance(NFT.address)).toString(), 88 | "User balance": (await provider.getBalance(owner.address)).toString(), 89 | claims: (await NFT.userToTokenBatches(owner.address)).length, 90 | }); 91 | }); 92 | 93 | it("Withdraws ETH as owner", async function () { 94 | console.log("Withdrawing eth."); 95 | await NFT.connect(owner).withdrawInitialFunds(); 96 | }); 97 | }); 98 | -------------------------------------------------------------------------------- /contracts/MDDA.sol: -------------------------------------------------------------------------------- 1 | //SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.4; 3 | 4 | import "@openzeppelin/contracts/access/Ownable.sol"; 5 | 6 | /* 7 | 8 | Open source Dutch Auction contract 9 | 10 | Dutch Auction that exposes a function to minters that allows them to pull difference between payment price and settle price. 11 | 12 | Initial version has no owner functions to not allow for owner foul play. 13 | 14 | Written by: mousedev.eth 15 | 16 | */ 17 | 18 | contract MDDA is Ownable { 19 | uint256 public DA_STARTING_PRICE; 20 | 21 | uint256 public DA_ENDING_PRICE; 22 | 23 | uint256 public DA_DECREMENT; 24 | 25 | uint256 public DA_DECREMENT_FREQUENCY; 26 | 27 | uint256 public DA_STARTING_TIMESTAMP; 28 | 29 | uint256 public DA_MAX_QUANTITY; 30 | 31 | //The price the auction ended at. 32 | uint256 public DA_FINAL_PRICE; 33 | 34 | //The quantity for DA. 35 | uint256 public DA_QUANTITY; 36 | 37 | bool public DATA_SET; 38 | 39 | bool public INITIAL_FUNDS_WITHDRAWN; 40 | 41 | //Struct for storing batch price data. 42 | struct TokenBatchPriceData { 43 | uint128 pricePaid; 44 | uint128 quantityMinted; 45 | } 46 | 47 | //Token to token price data 48 | mapping(address => TokenBatchPriceData[]) public userToTokenBatchPriceData; 49 | 50 | function initializeAuctionData( 51 | uint256 _DAStartingPrice, 52 | uint256 _DAEndingPrice, 53 | uint256 _DADecrement, 54 | uint256 _DADecrementFrequency, 55 | uint256 _DAStartingTimestamp, 56 | uint256 _DAMaxQuantity, 57 | uint256 _DAQuantity 58 | ) public onlyOwner { 59 | require(!DATA_SET, "DA data has already been set."); 60 | DA_STARTING_PRICE = _DAStartingPrice; 61 | DA_ENDING_PRICE = _DAEndingPrice; 62 | DA_DECREMENT = _DADecrement; 63 | DA_DECREMENT_FREQUENCY = _DADecrementFrequency; 64 | DA_STARTING_TIMESTAMP = _DAStartingTimestamp; 65 | DA_MAX_QUANTITY = _DAMaxQuantity; 66 | DA_QUANTITY = _DAQuantity; 67 | 68 | DATA_SET = true; 69 | } 70 | 71 | function userToTokenBatches(address user) 72 | public 73 | view 74 | returns (TokenBatchPriceData[] memory) 75 | { 76 | return userToTokenBatchPriceData[user]; 77 | } 78 | 79 | function currentPrice() public view returns (uint256) { 80 | require( 81 | block.timestamp >= DA_STARTING_TIMESTAMP, 82 | "DA has not started!" 83 | ); 84 | 85 | if (DA_FINAL_PRICE > 0) return DA_FINAL_PRICE; 86 | 87 | //Seconds since we started 88 | uint256 timeSinceStart = block.timestamp - DA_STARTING_TIMESTAMP; 89 | 90 | //How many decrements should've happened since that time 91 | uint256 decrementsSinceStart = timeSinceStart / DA_DECREMENT_FREQUENCY; 92 | 93 | //How much eth to remove 94 | uint256 totalDecrement = decrementsSinceStart * DA_DECREMENT; 95 | 96 | //If how much we want to reduce is greater or equal to the range, return the lowest value 97 | if (totalDecrement >= DA_STARTING_PRICE - DA_ENDING_PRICE) { 98 | return DA_ENDING_PRICE; 99 | } 100 | 101 | //If not, return the starting price minus the decrement. 102 | return DA_STARTING_PRICE - totalDecrement; 103 | } 104 | 105 | function DAHook(uint128 _quantity, uint256 _totalSupply) internal { 106 | require(DATA_SET, "DA data not set yet"); 107 | 108 | uint256 _currentPrice = currentPrice(); 109 | 110 | //Require enough ETH 111 | require( 112 | msg.value >= _quantity * _currentPrice, 113 | "Did not send enough eth." 114 | ); 115 | 116 | require( 117 | _quantity > 0 && _quantity <= DA_MAX_QUANTITY, 118 | "Incorrect quantity!" 119 | ); 120 | 121 | require( 122 | block.timestamp >= DA_STARTING_TIMESTAMP, 123 | "DA has not started!" 124 | ); 125 | 126 | require( 127 | _totalSupply + _quantity <= DA_QUANTITY, 128 | "Max supply for DA reached!" 129 | ); 130 | 131 | //Set the final price. 132 | if (_totalSupply + _quantity == DA_QUANTITY) 133 | DA_FINAL_PRICE = _currentPrice; 134 | 135 | //Add to user batch array. 136 | userToTokenBatchPriceData[msg.sender].push( 137 | TokenBatchPriceData(uint128(msg.value), _quantity) 138 | ); 139 | } 140 | 141 | function refundExtraETH() public { 142 | require(DA_FINAL_PRICE > 0, "Dutch action must be over!"); 143 | 144 | uint256 totalRefund; 145 | 146 | for ( 147 | uint256 i = userToTokenBatchPriceData[msg.sender].length; 148 | i > 0; 149 | i-- 150 | ) { 151 | //This is what they should have paid if they bought at lowest price tier. 152 | uint256 expectedPrice = userToTokenBatchPriceData[msg.sender][i - 1] 153 | .quantityMinted * DA_FINAL_PRICE; 154 | 155 | //What they paid - what they should have paid = refund. 156 | uint256 refund = userToTokenBatchPriceData[msg.sender][i - 1] 157 | .pricePaid - expectedPrice; 158 | 159 | //Remove this tokenBatch 160 | userToTokenBatchPriceData[msg.sender].pop(); 161 | 162 | //Send them their extra monies. 163 | totalRefund += refund; 164 | } 165 | payable(msg.sender).transfer(totalRefund); 166 | } 167 | 168 | function withdrawInitialFunds() public onlyOwner { 169 | require( 170 | !INITIAL_FUNDS_WITHDRAWN, 171 | "Initial funds have already been withdrawn." 172 | ); 173 | require(DA_FINAL_PRICE > 0, "DA has not finished!"); 174 | 175 | //Only pull the amount of ether that is the final price times how many were bought. This leaves room for refunds until final withdraw. 176 | uint256 initialFunds = DA_QUANTITY * DA_FINAL_PRICE; 177 | 178 | INITIAL_FUNDS_WITHDRAWN = true; 179 | 180 | (bool succ, ) = payable(msg.sender).call{value: initialFunds}(""); 181 | 182 | require(succ, "transfer failed"); 183 | } 184 | 185 | function withdrawFinalFunds() public onlyOwner { 186 | //Require this is 1 week after DA Start. 187 | require(block.timestamp >= DA_STARTING_TIMESTAMP + 604800); 188 | 189 | uint256 finalFunds = address(this).balance; 190 | 191 | (bool succ, ) = payable(msg.sender).call{value: finalFunds}(""); 192 | require(succ, "transfer failed"); 193 | } 194 | } 195 | --------------------------------------------------------------------------------