DEV Community

Cover image for Tutorial: Digital Signatures & NFT Whitelists
Rounak Banik
Rounak Banik

Posted on

Tutorial: Digital Signatures & NFT Whitelists

A Note on Terminology

A previous version of this article used the term whitelist instead of allowlist. Although they refer to the same thing, we have decided to update this article to use the latter in the interest of being more inclusive.

Introduction

Creating NFT allowlists has been, by far, the most requested topic in our developer community. Therefore, in this article, we will cover the following topics:

  1. Implementing allowlists on-chain and their cost implications
  2. Implementing allowlists off-chain using digital signatures

By the end of this tutorial, you should have an extremely good idea as to how to go about implementing allowlists in a secure and cost-efficient way, and in the process preventing unpleasant scenarios like gas wars.

Disclaimer

This article assumes that you have an intermediate knowledge of Solidity, Hardhat, and OpenZeppelin Contracts. If some of these terms sound alien to you, we strongly suggest you start here instead.

We also wanted to point out that not every NFT project requires an allowlist. We recommend you think about implementing one only if you have an active and vibrant community, and your projected demand for your NFTs far exceeds supply. For 99.9% of the projects out there, this simply isn’t true. Therefore, trying to implement allowlists will not only result in wastage of resources that could be spent elsewhere but could also backfire by repelling the few backers that your project has should you not be able to fill all slots.

Implementing Allowlists On-Chain

On chain

On-chain allowlists are secure and fairly easy to implement. We will be using the NFT Collectible Contract from a previous tutorial as our base.

These are the following additions that we need to make to our contract.

  1. A global mapping variable isAllowlistAddress that keeps track of all the addresses that have been allowlisted.
  2. A function allowlistAddress that is callable only by the contract’s owner and that can add one or more addresses to isAllowlistAddress mapping.
  3. A preSale function that is very similar to the mintNfts function except that it only allows allowlisted addresses to mint at a pre-sale price.

We can define the mapping variable as follows:

mapping(address => bool) public isAllowlistAddress;
Enter fullscreen mode Exit fullscreen mode

Next, let’s write a allowlisting function that allows the contract’s owner to add a list of addresses to the aforementioned mapping.

// Allowlist addresses
function allowlistAddresses(address[] calldata wAddresses) public onlyOwner {
    for (uint i = 0; i < wAddresses.length; i++) {
        isAllowlistAddress[wAddresses[i]] = true;
    }
}
Enter fullscreen mode Exit fullscreen mode

Finally, let’s write a preSale function that allows only allowlisted addresses to mint.

// Presale mints
function preSale(uint _count) public payable {
    uint totalMinted = _tokenIds.current();
    uint preSalePrice = 0.005 ether;
    uint preSaleMaxMint = 2;

    require(totalMinted.add(_count) <= MAX_SUPPLY, 
            "Not enough NFTs left!");
    require(_count >0 && _count <= preSaleMaxMint, 
            "Cannot mint specified number of NFTs.");
    require(msg.value >= preSalePrice.mul(_count), 
            "Not enough ether to purchase NFTs.");
    require(isAllowlistAddress[msg.sender], 
            "Address is not allowlisted");
    for (uint i = 0; i < _count; i++) {
        _mintSingleNFT();
    }

    isAllowlistAddress[msg.sender] = false;
}
Enter fullscreen mode Exit fullscreen mode

Notice that this function is very similar to the mintNfts function that we already have in our contract. We use a different price and mint limit for presale. We also place an additional check to ensure only allowlisted addresses can mint. Finally, we remove the address from the allowlist to ensure that the wallet does not mint more than once.

Your final contract should look something like this:

//SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import "@openzeppelin/contracts/utils/Counters.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/utils/math/SafeMath.sol";
import "@openzeppelin/contracts/token/ERC721/extensions/ERC721Enumerable.sol";

contract NFTCollectible is ERC721Enumerable, Ownable {
    using SafeMath for uint256;
    using Counters for Counters.Counter;

    Counters.Counter private _tokenIds;

    mapping(address => bool) public isAllowlistAddress;

    uint public constant MAX_SUPPLY = 100;
    uint public constant PRICE = 0.01 ether;
    uint public constant MAX_PER_MINT = 5;

    string public baseTokenURI;

    constructor(string memory baseURI) ERC721("NFT Collectible", "NFTC") {
        setBaseURI(baseURI);
    }

    // Allowlist addresses
    function allowlistAddresses(address[] calldata wAddresses) public onlyOwner {
        for (uint i = 0; i < wAddresses.length; i++) {
            isAllowlistAddress[wAddresses[i]] = true;
        }
    }

    function reserveNFTs() public onlyOwner {
        uint totalMinted = _tokenIds.current();

        require(totalMinted.add(10) < MAX_SUPPLY, "Not enough NFTs left to reserve");

        for (uint i = 0; i < 10; i++) {
            _mintSingleNFT();
        }
    }

    function _baseURI() internal view virtual override returns (string memory) {
        return baseTokenURI;
    }

    function setBaseURI(string memory _baseTokenURI) public onlyOwner {
        baseTokenURI = _baseTokenURI;
    }

    // Presale mints
    function preSale(uint _count) public payable {
        uint totalMinted = _tokenIds.current();
        uint preSalePrice = 0.005 ether;
        uint preSaleMaxMint = 2;

        require(totalMinted.add(_count) <= MAX_SUPPLY, "Not enough NFTs left!");
        require(_count >0 && _count <= preSaleMaxMint, "Cannot mint specified number of NFTs.");
        require(msg.value >= preSalePrice.mul(_count), "Not enough ether to purchase NFTs.");
        require(isAllowlistAddress[msg.sender], "Address is not allowlisted");

        for (uint i = 0; i < _count; i++) {
            _mintSingleNFT();
        }

        isAllowlistAddress[msg.sender] = false;        
    }

    function mintNFTs(uint _count) public payable {
        uint totalMinted = _tokenIds.current();

        require(totalMinted.add(_count) <= MAX_SUPPLY, "Not enough NFTs left!");
        require(_count >0 && _count <= MAX_PER_MINT, "Cannot mint specified number of NFTs.");
        require(msg.value >= PRICE.mul(_count), "Not enough ether to purchase NFTs.");

        for (uint i = 0; i < _count; i++) {
            _mintSingleNFT();
        }
    }

    function _mintSingleNFT() private {
        uint newTokenID = _tokenIds.current();
        _safeMint(msg.sender, newTokenID);
        _tokenIds.increment();
    }

    function tokensOfOwner(address _owner) external view returns (uint[] memory) {

        uint tokenCount = balanceOf(_owner);
        uint[] memory tokensId = new uint256[](tokenCount);

        for (uint i = 0; i < tokenCount; i++) {
            tokensId[i] = tokenOfOwnerByIndex(_owner, i);
        }
        return tokensId;
    }

    function withdraw() public payable onlyOwner {
        uint balance = address(this).balance;
        require(balance > 0, "No ether left to withdraw");

        (bool success, ) = (msg.sender).call{value: balance}("");
        require(success, "Transfer failed.");
    }

}
Enter fullscreen mode Exit fullscreen mode

The problem with on-chain allowlists

The implementation we’ve used so far is secure and does exactly what it needs to do.

However, this implementation is wildly inefficient. The root cause of this is the allowlistAddresses function that can only be called by the contract’s owner. By its very design, this contract expects the owner to populate the mapping with all possible allowlisted addresses.

Depending on the size of your allowlist, this process could prove to be computationally intensive and extremely expensive. You may be able to get away with this if you’re operating on a sidechain like Polygon or Binance Smart chain but on Ethereum, even modest-sized allowlists will set you back by several thousands of dollars.

Fortunately, it is possible to implement allowlists securely off-chain without having to deal with extortionate gas fees. We can achieve this using digital signatures.

Digital Signatures

Digital Signatures by Yos Riady

Digital Signatures and public key cryptography are central to virtually everything that happens on a blockchains like Bitcoin and Ethereum. We won’t be covering how signatures work in this article (we have a series on cryptography coming very soon!). Instead, we will just acquire a black-box understanding of how it works.

As most of you already know, we interact with Ethereum using a wallet which is associated with two keys: a public key (or wallet address) and a private key.

Using cryptography, it is possible for a person to prove that s/he holds the private key of a particular wallet address without revealing the key itself. It should be obvious why this is very important. If we couldn’t initiate transactions using our private key without revealing said key, the system would break down completely as there would be no way to authenticate yourself securely and trustlessly.

Digital cryptographic signatures allow us to accomplish the following:

  1. The signer is able to sign a message using a private key and broadcast the signed message.
  2. It is impossible to recover the private key by simply looking at the message and/or the public key.
  3. It is however possible to verify that the signer holds the correct private key using the public key (or wallet address).

If this sounds a little magical, it’s because it is. The feats possible by public key cryptography are nothing short of miraculous. However, as stated earlier, we will cover this in detail in a future series.

With this basic understanding of how digital signatures work, we can now propose the following system of implementing allowlists.

  1. Create a centralized server and database that holds all the addresses that are allowlisted.
  2. When a wallet tries to initiate a mint on your website, send the wallet address to your server.
  3. The server checks if the address has been allowlisted and if it has, it signs the wallet address with a private key that is known only to the project’s creator.
  4. The server returns the signed message to the frontend client (or website) and this in turn, is sent to the smart contract.
  5. The contract’s mint function verifies that the message sent was indeed signed by the wallet controlled by the owner. If the verification succeeds, minting is allowed.
  6. The signed message is stored in a mapping to prevent it from being used more than once or by multiple wallets.

(We will not be implementing a real server or using a real database in this article. If this is something that you’ve never done before, taking a look at Express and Mongo tutorials would be a good place to start.)

Signing Messages

In your Hardhat project, create a new file called allowlist.js in the scripts folder.

We will be using the ethers library to sign our messages. Let’s allowlist Hardhat’s default accounts 1 to 5 for this example.

const ethers = require('ethers');
const main = async () => {
    const allowlistedAddresses = [
        '0x70997970c51812dc3a010c7d01b50e0d17dc79c8',
        '0x3c44cdddb6a900fa2b585dd299e03d12fa4293bc',
        '0x90f79bf6eb2c4f870365e785982e1f101e93b906',
        '0x15d34aaf54267db7d7c367839aaf71a00a2c6a65',
        '0x9965507d1a55bcc2695c58ba16fb37d819b0a4dc',
    ];
}

const runMain = async () => {
    try {
        await main(); 
        process.exit(0);
    }
    catch (error) {
        console.log(error);
        process.exit(1);
    }
};

runMain();
Enter fullscreen mode Exit fullscreen mode

These are the only addresses that will be allowed to mint in the presale. Let’s use Account 0 as the owner’s wallet.

const owner = '0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266';

const privateKey = '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80';

const signer = new ethers.Wallet(privateKey);
console.log(signer.address)
Enter fullscreen mode Exit fullscreen mode

Run this script by running node scripts/allowlist.js in the terminal. If all goes well, the wallet address printed to the console should be the same as that assigned to owner.

Let’s now sign a simple message and see how that works.

let message = 'Hello World!'

let signature = await signer.signMessage(message)
console.log(signature);
Enter fullscreen mode Exit fullscreen mode

Running this script will output a signed message 0xdd4...61c.

In our case, we will not be signing a message written in English. Instead, we will be signing the hash of a allowlisted wallet address (which is nothing but a hash itself). Ethers documentation recommends that we convert binary hash data into an array before signing it.

Let’s sign the hash of the first allowlisted address from above. Replace the code snippet above with the following.

// Get first allowlisted address
let message = allowlistedAddresses[0];

// Compute hash of the address
let messageHash = ethers.utils.id(message);
console.log("Message Hash: ", messageHash);

// Sign the hashed address
let messageBytes = ethers.utils.arrayify(messageHash);
let signature = await signer.signMessage(messageBytes);
console.log("Signature: ", signature);
Enter fullscreen mode Exit fullscreen mode

Running this snippet will output 0xee...c1b as signature.

Therefore, when a wallet issues a request to the server, you server will need to do two things:

  1. Check if the wallet is a part of allowlistedAddresses
  2. If yes, sign the hashed wallet address with the supplied private key and return the signature and the hashed wallet address.

Verifying Signatures

Verify signatures

Verifying signatures is extremely simple using OpenZeppelin’s ECDSA library.

Let’s start with our base NFTCollectible.sol contract again. As a first step, we will write a recoverSigner function that will take the hashed allowlisted wallet address and the signature as arguments and output the address of the signer.

function recoverSigner(bytes32 hash, bytes memory signature) public pure returns (address) {
    bytes32 messageDigest = keccak256(
        abi.encodePacked(
            "\x19Ethereum Signed Message:\n32", 
            hash
        )
    );
    return ECDSA.recover(messageDigest, signature);
}
Enter fullscreen mode Exit fullscreen mode

Let’s open up a new Terminal and spin up a local instance of Ethereum using the following command:

npx hardhat node
Enter fullscreen mode Exit fullscreen mode

Next, let’s write code in allowlist.js that compiles and deploys the contract to our local blockchain and calls the recoverSigner function.

const nftContractFactory = await hre.ethers.getContractFactory('NFTCollectible');
const nftContract = await nftContractFactory.deploy(
    "ipfs://your-cide-code"
);

await nftContract.deployed();

console.log("Contract deployed by: ", signer.address);
recover = await nftContract.recoverSigner(messageHash, signature);
console.log("Message was signed by: ", recover.toString());
Enter fullscreen mode Exit fullscreen mode

Let’s run this script using:

npx hardhat run scripts/allowlist.js --network localhost
Enter fullscreen mode Exit fullscreen mode

If all goes well, you should see your console telling you that the message was signed by the same wallet that deployed the contract.

Terminal

Amazing work! We now have all the pieces we need to implement our preSale function and by extension, allowlisting.

Let’s define a mapping that will track if a particular signature has already been used to mint.

mapping(bytes => bool) public signatureUsed;
Enter fullscreen mode Exit fullscreen mode

Finally, let’s write our preSale function.

function preSale(uint _count, bytes32 hash, bytes memory signature) public payable {
    uint totalMinted = _tokenIds.current();
    uint preSalePrice = 0.005 ether;
    uint preSaleMaxMint = 2;

    require(totalMinted.add(_count) <= MAX_SUPPLY, 
            "Not enough NFTs left!");
    require(_count >0 && _count <= preSaleMaxMint, 
            "Cannot mint specified number of NFTs.");
    require(msg.value >= preSalePrice.mul(_count), 
           "Not enough ether to purchase NFTs.");
    require(recoverSigner(hash, signature) == owner(), 
            "Address is not allowlisted");
    require(!signatureUsed[signature], 
            "Signature has already been used.");

    for (uint i = 0; i < _count; i++) {
        _mintSingleNFT();
    }
    signatureUsed[signature] = true;
}
Enter fullscreen mode Exit fullscreen mode

Congratulations! You have successfully implemented an allowlisting mechanism that works off-chain but is just as secure as its on-chain counterpart.

Here is the final contract.

//SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
import "@openzeppelin/contracts/utils/Counters.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/utils/math/SafeMath.sol";
import "@openzeppelin/contracts/token/ERC721/extensions/ERC721Enumerable.sol";

contract NFTCollectible is ERC721Enumerable, Ownable {
    using SafeMath for uint256;
    using Counters for Counters.Counter;

    Counters.Counter private _tokenIds;

    mapping(bytes => bool) public signatureUsed;

    uint public constant MAX_SUPPLY = 100;
    uint public constant PRICE = 0.01 ether;
    uint public constant MAX_PER_MINT = 5;

    string public baseTokenURI;

    constructor(string memory baseURI) ERC721("NFT Collectible", "NFTC") {
        setBaseURI(baseURI);
    }

    function reserveNFTs() public onlyOwner {
        uint totalMinted = _tokenIds.current();

        require(totalMinted.add(10) < MAX_SUPPLY, "Not enough NFTs left to reserve");

        for (uint i = 0; i < 10; i++) {
            _mintSingleNFT();
        }
    }

    function _baseURI() internal view virtual override returns (string memory) {
        return baseTokenURI;
    }

    function setBaseURI(string memory _baseTokenURI) public onlyOwner {
        baseTokenURI = _baseTokenURI;
    }

    function recoverSigner(bytes32 hash, bytes memory signature) public pure returns (address) {
        bytes32 messageDigest = keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", hash));
        return ECDSA.recover(messageDigest, signature);
    }

    function mintNFTs(uint _count) public payable {
        uint totalMinted = _tokenIds.current();

        require(totalMinted.add(_count) <= MAX_SUPPLY, "Not enough NFTs left!");
        require(_count >0 && _count <= MAX_PER_MINT, "Cannot mint specified number of NFTs.");
        require(msg.value >= PRICE.mul(_count), "Not enough ether to purchase NFTs.");

        for (uint i = 0; i < _count; i++) {
            _mintSingleNFT();
        }
    }

    function preSale(uint _count, bytes32 hash, bytes memory signature) public payable {
        uint totalMinted = _tokenIds.current();
        uint preSalePrice = 0.005 ether;
        uint preSaleMaxMint = 2;

        require(totalMinted.add(_count) <= MAX_SUPPLY, "Not enough NFTs left!");
        require(_count >0 && _count <= preSaleMaxMint, "Cannot mint specified number of NFTs.");
        require(msg.value >= preSalePrice.mul(_count), "Not enough ether to purchase NFTs.");
        require(recoverSigner(hash, signature) == owner(), "Address is not allowlisted");
        require(!signatureUsed[signature], "Signature has already been used.");

        for (uint i = 0; i < _count; i++) {
            _mintSingleNFT();
        }

        signatureUsed[signature] = true;
    }

    function _mintSingleNFT() private {
        uint newTokenID = _tokenIds.current();
        _safeMint(msg.sender, newTokenID);
        _tokenIds.increment();
    }

    function tokensOfOwner(address _owner) external view returns (uint[] memory) {

        uint tokenCount = balanceOf(_owner);
        uint[] memory tokensId = new uint256[](tokenCount);

        for (uint i = 0; i < tokenCount; i++) {
            tokensId[i] = tokenOfOwnerByIndex(_owner, i);
        }
        return tokensId;
    }

    function withdraw() public payable onlyOwner {
        uint balance = address(this).balance;
        require(balance > 0, "No ether left to withdraw");

        (bool success, ) = (msg.sender).call{value: balance}("");
        require(success, "Transfer failed.");
    }

}
Enter fullscreen mode Exit fullscreen mode

To summarize once again, this is how pre-sale minting would work:

  1. A buyer visits your website, connects wallet, specifies the number of NFTs s/he wants to mint, and clicks on the Mint NFT button.
  2. This initiates a request to your centralized server which checks if the address has been allowlisted. If yes, it sends back the hashed wallet address and the signature. If no, it returns an error.
  3. Your website takes the aforementioned values and initiates a transaction to your smart contract on behalf of the user.
  4. In the smart contract, the preSale function verifies that the signature was indeed signed by you and allows minting to take place.

Conclusion

This is by far the most technical article we’ve published so far. If you’ve understood major portions of what’s going on, then congratulations! You are well on your way to becoming an expert Solidity developer.

If you find yourself struggling, don’t worry about it. It may be a little hard to digest this in one go. We would suggest you complement this article with alternate resources and tutorials on the topic.

We should also mention that digital signatures aren’t the only way to achieve off-chain allowlists. It is possible to use Merkle trees to arrive at the same result. We will be releasing an article on that sometime in the future.

If you have any questions or are stuck, reach out to us on our Discord.

If you don’t have questions, come say hi to us on our Discord anyway! Also, if you liked our content, we would be super grateful if you tweet about us, follow us(@ScrappyNFTs and @Rounak_Banik), and invite your circle to our Discord. Thank you for your support!

About Scrappy Squirrels

Scrappy Squirrels is a collection of 10,000+ randomly generated NFTs. Scrappy Squirrels are meant for buyers, creators, and developers who are completely new to the NFT ecosystem.

The community is built around learning about the NFT revolution, exploring its current use cases, discovering new applications, and finding members to collaborate on exciting projects with.

Join our community here: https://discord.gg/8UqJXTX7Kd

Latest comments (3)

Collapse
 
felixdcl profile image
felixdcl

i´ve got a question
const owner = 'thisismywallet';

const privateKey = '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80';
but what is this privatekey?

const signer = new ethers.Wallet(privateKey);
console.log(signer.address)

Collapse
 
meriembader profile image
Meriem BADER • Edited

it is not a good practice to use the private key of the metamask directly in the code The point of Metamask as a product is to never expose the private key to the web applications that the user browses to.

Collapse
 
shaheemkhanzada profile image
Shaheem khan

well you are not wrong but in this case we are only saving contract owner private key on backend inside env file not within code so its secure