├── .gitignore ├── tsconfig.json ├── notes ├── hardhat.config.ts ├── package.json ├── test ├── signWhitelist.ts └── test-token.ts ├── contracts ├── NFT.sol └── EIP712Whitelisting.sol └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .env 3 | coverage 4 | coverage.json 5 | typechain 6 | 7 | #Hardhat files 8 | cache 9 | artifacts 10 | 11 | # VS Code 12 | .vscode -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2018", 4 | "module": "commonjs", 5 | "strict": true, 6 | "esModuleInterop": true, 7 | "outDir": "dist" 8 | }, 9 | "include": ["./scripts", "./test"], 10 | "files": ["./hardhat.config.ts"] 11 | } -------------------------------------------------------------------------------- /notes: -------------------------------------------------------------------------------- 1 | https://docs.ethers.io/v5/api/signer/#Signer--signing-methods 2 | https://etherscan.io/address/0xf497253c2bb7644ebb99e4d9ecc104ae7a79187a#code 3 | EIP-712 4 | https://github.com/ethereum/EIPs/blob/master/EIPS/eip-712.md 5 | https://twitter.com/jacobdehart/status/1435337764338339840 6 | https://medium.com/metamask/eip712-is-coming-what-to-expect-and-how-to-use-it-bb92fd1a7a26 7 | https://github.com/sidsverma/eth_signTypedData-example // simple example of both sides -------------------------------------------------------------------------------- /hardhat.config.ts: -------------------------------------------------------------------------------- 1 | import "@nomiclabs/hardhat-waffle"; 2 | import "@nomiclabs/hardhat-web3"; 3 | import { HardhatUserConfig } from "hardhat/config"; 4 | 5 | // You need to export an object to set up your config 6 | // Go to https://hardhat.org/config/ to learn more 7 | 8 | /** 9 | * @type import('hardhat/config').HardhatUserConfig 10 | */ 11 | const config : HardhatUserConfig = { 12 | solidity: "0.8.4", 13 | 14 | }; 15 | 16 | export default config 17 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "whitelisting-nfts", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "author": "Michael Feldstein", 6 | "license": "MIT", 7 | "scripts": { 8 | "test": "hardhat test" 9 | }, 10 | "dependencies": { 11 | "@openzeppelin/contracts": "^4.3.2", 12 | "hardhat": "^2.6.7" 13 | }, 14 | "devDependencies": { 15 | "@nomiclabs/hardhat-ethers": "^2.0.0", 16 | "@nomiclabs/hardhat-waffle": "^2.0.0", 17 | "@nomiclabs/hardhat-web3": "^2.0.0", 18 | "@openzeppelin/test-helpers": "^0.5.15", 19 | "@types/chai": "^4.2.22", 20 | "@types/mocha": "^9.0.0", 21 | "@types/node": "^16.11.6", 22 | "chai": "^4.3.4", 23 | "ethereum-waffle": "^3.0.0", 24 | "ethers": "^5.0.0", 25 | "ts-node": "^10.7.0", 26 | "typescript": "^4.6.4" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /test/signWhitelist.ts: -------------------------------------------------------------------------------- 1 | import { SignerWithAddress } from "@nomiclabs/hardhat-ethers/signers"; 2 | 3 | export default async function signWhitelist( 4 | chainId: number, 5 | contractAddress: string, 6 | whitelistKey: SignerWithAddress, 7 | mintingAddress: string 8 | ) { 9 | // Domain data should match whats specified in the DOMAIN_SEPARATOR constructed in the contract 10 | // https://github.com/msfeldstein/EIP712-whitelisting/blob/main/contracts/EIP712Whitelisting.sol#L33-L43 11 | const domain = { 12 | name: "WhitelistToken", 13 | version: "1", 14 | chainId, 15 | verifyingContract: contractAddress, 16 | }; 17 | 18 | // The types should match the TYPEHASH specified in the contract 19 | // https://github.com/msfeldstein/EIP712-whitelisting/blob/main/contracts/EIP712Whitelisting.sol#L27-L28 20 | const types = { 21 | Minter: [{ name: "wallet", type: "address" }], 22 | }; 23 | 24 | const sig = await whitelistKey._signTypedData(domain, types, { 25 | wallet: mintingAddress, 26 | }); 27 | 28 | return sig 29 | } 30 | -------------------------------------------------------------------------------- /contracts/NFT.sol: -------------------------------------------------------------------------------- 1 | //SPDX-License-Identifier: Unlicense 2 | pragma solidity ^0.8.0; 3 | 4 | import "hardhat/console.sol"; 5 | import "@openzeppelin/contracts/token/ERC721/ERC721.sol"; 6 | import "@openzeppelin/contracts/utils/Counters.sol"; 7 | import "@openzeppelin/contracts/utils/Strings.sol"; 8 | import "./EIP712Whitelisting.sol"; 9 | 10 | contract NFT is ERC721, EIP712Whitelisting { 11 | using Counters for Counters.Counter; 12 | using Strings for uint256; 13 | 14 | Counters.Counter private _tokenIdCounter; 15 | 16 | constructor() ERC721("WhitelistToken", "TOKE") EIP712Whitelisting() {} 17 | 18 | // Use the requiresWhitelist modifier to reject the call if a valid signature is not provided 19 | function whitelistMint(bytes calldata signature) 20 | public 21 | requiresWhitelist(signature) 22 | { 23 | // Make sure to check other requirements before incrementing or minting 24 | _tokenIdCounter.increment(); 25 | _safeMint(msg.sender, _tokenIdCounter.current()); 26 | } 27 | 28 | function tokenURI() public pure returns (string memory) { 29 | return 30 | "ipfs://bafybeiaqofrinid75krvga6c2alksixzmhuddx3zxgwvmyhh7vsyjbv6tm"; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /test/test-token.ts: -------------------------------------------------------------------------------- 1 | import { Contract } from "@ethersproject/contracts"; 2 | import { SignerWithAddress } from "@nomiclabs/hardhat-ethers/signers"; 3 | import { ethers } from "hardhat"; 4 | import signWhitelist from "./signWhitelist"; 5 | const { expectRevert } = require("@openzeppelin/test-helpers"); 6 | 7 | describe("Token", function () { 8 | let contract: Contract; 9 | let mintingKey: SignerWithAddress; 10 | let whitelistKey: SignerWithAddress; 11 | let maliciousKey: SignerWithAddress; 12 | 13 | beforeEach(async function () { 14 | const accounts = await ethers.getSigners(); 15 | mintingKey = accounts[0]; 16 | whitelistKey = accounts[1]; 17 | maliciousKey = accounts[2]; 18 | 19 | const Token = await ethers.getContractFactory("NFT"); 20 | contract = await Token.deploy(); 21 | await contract.deployed(); 22 | }); 23 | 24 | it("Should allow minting with whitelist enabled if a valid signature is sent", async function () { 25 | await contract.setWhitelistSigningAddress(whitelistKey.address); 26 | let { chainId } = await ethers.provider.getNetwork(); 27 | const sig = signWhitelist( 28 | chainId, 29 | contract.address, 30 | whitelistKey, 31 | mintingKey.address 32 | ); 33 | await contract.whitelistMint(sig); 34 | }); 35 | 36 | it("Should not allow minting if whitelist is not enabled in the contract (missing whitelist address in contract)", async function () { 37 | let { chainId } = await ethers.provider.getNetwork(); 38 | const sig = signWhitelist( 39 | chainId, 40 | contract.address, 41 | whitelistKey, 42 | mintingKey.address 43 | ); 44 | await expectRevert(contract.whitelistMint(sig), "whitelist not enabled"); 45 | }); 46 | 47 | it("Should not allow minting with whitelist enabled if the signature was generated with an incorrect signing key", async function () { 48 | await contract.setWhitelistSigningAddress(whitelistKey.address); 49 | let { chainId } = await ethers.provider.getNetwork(); 50 | const sig = signWhitelist( 51 | chainId, 52 | contract.address, 53 | maliciousKey, 54 | mintingKey.address 55 | ); 56 | await expectRevert(contract.whitelistMint(sig), "Invalid Signature"); 57 | }); 58 | 59 | it("Should not allow minting with whitelist enabled if a signature is sent by someone who is not the address from the signature", async function () { 60 | await contract.setWhitelistSigningAddress(whitelistKey.address); 61 | let { chainId } = await ethers.provider.getNetwork(); 62 | const sig = signWhitelist( 63 | chainId, 64 | contract.address, 65 | whitelistKey, 66 | maliciousKey.address 67 | ); 68 | await expectRevert(contract.whitelistMint(sig), "Invalid Signature"); 69 | }); 70 | }); 71 | -------------------------------------------------------------------------------- /contracts/EIP712Whitelisting.sol: -------------------------------------------------------------------------------- 1 | //SPDX-License-Identifier: Unlicense 2 | pragma solidity ^0.8.0; 3 | 4 | import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; 5 | import "@openzeppelin/contracts/access/Ownable.sol"; 6 | 7 | contract EIP712Whitelisting is Ownable { 8 | using ECDSA for bytes32; 9 | 10 | // The key used to sign whitelist signatures. 11 | // We will check to ensure that the key that signed the signature 12 | // is this one that we expect. 13 | address whitelistSigningKey = address(0); 14 | 15 | // Domain Separator is the EIP-712 defined structure that defines what contract 16 | // and chain these signatures can be used for. This ensures people can't take 17 | // a signature used to mint on one contract and use it for another, or a signature 18 | // from testnet to replay on mainnet. 19 | // It has to be created in the constructor so we can dynamically grab the chainId. 20 | // https://github.com/ethereum/EIPs/blob/master/EIPS/eip-712.md#definition-of-domainseparator 21 | bytes32 public DOMAIN_SEPARATOR; 22 | 23 | // The typehash for the data type specified in the structured data 24 | // https://github.com/ethereum/EIPs/blob/master/EIPS/eip-712.md#rationale-for-typehash 25 | // This should match whats in the client side whitelist signing code 26 | // https://github.com/msfeldstein/EIP712-whitelisting/blob/main/test/signWhitelist.ts#L22 27 | bytes32 public constant MINTER_TYPEHASH = 28 | keccak256("Minter(address wallet)"); 29 | 30 | constructor() { 31 | // This should match whats in the client side whitelist signing code 32 | // https://github.com/msfeldstein/EIP712-whitelisting/blob/main/test/signWhitelist.ts#L12 33 | DOMAIN_SEPARATOR = keccak256( 34 | abi.encode( 35 | keccak256( 36 | "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)" 37 | ), 38 | // This should match the domain you set in your client side signing. 39 | keccak256(bytes("WhitelistToken")), 40 | keccak256(bytes("1")), 41 | block.chainid, 42 | address(this) 43 | ) 44 | ); 45 | } 46 | 47 | function setWhitelistSigningAddress(address newSigningKey) public onlyOwner { 48 | whitelistSigningKey = newSigningKey; 49 | } 50 | 51 | modifier requiresWhitelist(bytes calldata signature) { 52 | require(whitelistSigningKey != address(0), "whitelist not enabled"); 53 | // Verify EIP-712 signature by recreating the data structure 54 | // that we signed on the client side, and then using that to recover 55 | // the address that signed the signature for this data. 56 | bytes32 digest = keccak256( 57 | abi.encodePacked( 58 | "\x19\x01", 59 | DOMAIN_SEPARATOR, 60 | keccak256(abi.encode(MINTER_TYPEHASH, msg.sender)) 61 | ) 62 | ); 63 | // Use the recover method to see what address was used to create 64 | // the signature on this data. 65 | // Note that if the digest doesn't exactly match what was signed we'll 66 | // get a random recovered address. 67 | address recoveredAddress = digest.recover(signature); 68 | require(recoveredAddress == whitelistSigningKey, "Invalid Signature"); 69 | _; 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Gas free whitelisting with EIP-712 signing 2 | 3 | 4 | NFT launches may want to utilize some type of whitelist to make their launches more fair or appealing to their community and avoid gas wars and bots. A few approaches one can take are: 5 | 6 | - Writing whitelisted accounts into contract storage. Super expensive to deploy but also dead simple 7 | - Storing a merkle root to all the addresses that are whitelisted, and having the minter submit a merkle proof that their in that list. This is much cheaper, but a bit arduous to add people to, especially once things are deployed (though possible). [[Needs citation]] 8 | - This repos strategy: Signature verification using EIP-712. No gas, simple for end users to get a signature created using a basic web2 web service. 9 | 10 | ## How's it work? 11 | In order to prove that someone has been approved by the project we can have a whitelisting key owned by the project sign some structured data that includes the minting accounts address. This can be done manually or with a web service that authenticates users somehow. 12 | 13 | [EIP-712](https://github.com/ethereum/EIPs/blob/master/EIPS/eip-712.md) provides a scheme for encoding structs of data to be signed which is important so that we can recreate the exact same digest of data both client side, when generating the signature, and in the contract. As long as the digest is exactly the same bytes, we can use solidity's `ecrecover` method to see which public key created the signature of the digest passed in. If the signature was created by the whitelisting key, and the minting account is part of the signed data, we know that the minting account has been approved by the project. 14 | 15 | ## Examples 16 | 17 | ### [Signature Generation](https://github.com/msfeldstein/EIP712-whitelisting/blob/main/test/signWhitelist.ts) 18 | 19 | To generate the signature needed to mint, we can look at example code in [signWhitelist.ts](https://github.com/msfeldstein/EIP712-whitelisting/blob/main/test/signWhitelist.ts). We can see that we create whats called a [domain separator](https://github.com/msfeldstein/EIP712-whitelisting/blob/main/test/signWhitelist.ts#L12-L17) which is used to make sure one contracts signature can't be replayed into another contract, or from testnet to mainnet. We also create the [typehash](https://github.com/msfeldstein/EIP712-whitelisting/blob/main/test/signWhitelist.ts#L21-L23) we use to describe how the data is structured. All this data needs to exactly match what we use in the solidity contract. We then use ethers' [signTypedData](https://github.com/msfeldstein/EIP712-whitelisting/blob/main/test/signWhitelist.ts#L25-L27) function to sign the structured data and return the signature. 20 | 21 | ### [Signature Verification](https://github.com/msfeldstein/EIP712-whitelisting/blob/main/contracts/EIP712Whitelisting.sol) 22 | 23 | I've created an [EIP712Whitelisting contract](https://github.com/msfeldstein/EIP712-whitelisting/blob/main/contracts/EIP712Whitelisting.sol) that you can inherit to get the [requiresWhitelist modifier](https://github.com/msfeldstein/EIP712-whitelisting/blob/main/contracts/EIP712Whitelisting.sol#L55) that you can use to protect public calls with a whitelisting requirement. To enforce this we need to [recreate the exact digest that we expected to be signed](https://github.com/msfeldstein/EIP712-whitelisting/blob/main/contracts/EIP712Whitelisting.sol#L59-L65) (essentially the domain separator described above and the address of the minting account), and then we can take the digest and the signature and use `ecrecover` to see what account created the signature, and make sure its the one we expect. 24 | 25 | @msfeldstein on twitter if you have any questions or comments --------------------------------------------------------------------------------