How to create an off-chain NFT whitelist

This technical article will explore the concept of NFT whitelisting and how to implement this for an Ethereum based NFT collection.

First, let’s define what NFT whitelisting is: it’s the process of getting a crypto wallet address pre-approved for minting.

It’s a common approach to prevent so called “gas wars”, where people raise the price of gas that they’re willing to pay to mint an NFT so that their transactions are picked up first, and can be a useful marketing tool where people are added to the whitelist after taking certain actions (for e.g.: in exchange for signing up to an email newsletter).

This article will walk you through the steps involved in implementing this type of system using smart contracts on the Ethereum blockchain.

While there are multiple valid approaches, we’ll be using a coupon system where wallet addresses are signed off-chain in a way that the smart contract can verify that it comes from a trusted source.

By the end of this article you’ll be able to:

  • Add “whitelisting” functionality to your smart contract that will allow pre-approved wallets to mint a single NFT.
  • Architect a web solution that integrates with your smart contract’s whitelist.

How will it work?

Generation

Each coupon will be a simple javascript object containing a wallet address that is signed off-chain using a private key that’s only known to us.

Retrieval

Our coupons will live in a simple JSON file, and will be exposed via a simple API.

Consumption

The resulting coupon signature can be used when calling our smart contract to prove that the data being received was generated by us.

Let’s build it

Generation

Let’s start by generating our coupons. We’ll use public-key cryptography to encrypt a wallet address inside our “coupon”.

The script below exposes a createCoupon function that accepts an address and privateKey and will return a coupon.

const { ethers } = require(‘ethers’);

async function createCoupon(address, privateKey) {
    // We’ll leverage the ethers library to create a new wallet
    // that we’ll use to sign the coupon with the private key.    
    const signer = new ethers.Wallet(privateKey);    

    // We need to encode the wallet address in a way that
    // can be signed and later recovered from the smart contract.
    // Hashing the address using the SHA-256 hashing algorithm
    // is a good way to do this.   
 
    const message = ethers.utils.solidityKeccak256(
        [‘address’],
        [address]
    );    

    // Now we can sign the message using the private key.    
    const signature = await   signer.signMessage(ethers.utils.arrayify(message));    

    // The signature can be expanded into it’s underlying components
    // which we can pass directly into the smart contract.
    // If we didn’t split the signature here — we’d have to do it
    // in the smart contract, which is a bit of a hassle.    

    let { r, s, v } = ethers.utils.splitSignature(signature);    
    return {r,s,v}
}

module.exports = {
    createCoupon,
}

We’ll need a key-pair to work with — the same one used to deploy your contract will work, but if you still need to generate one you can quickly create a new wallet with Metamask and export the private key from there.

Here’s a small node.js script that will generate our coupons:

const { createCoupon } = require("./coupons");
const fs = require('fs');
require("dotenv").config();// Insert private key corresponding to _couponSigner
const privateKey = process.env.PRIVATE_KEY;// Populate with addresses to whitelist
let addresses = [
// ..
];const main = async () => {
let output = [];
console.log('Generating...');
for (let i = 0; i < addresses.length; i++) {
let signature = await createCoupon(addresses[i], 0, privateKey);
output.push({
wallet: mint[i],
r: signature.r,
s: signature.s,
v: signature.v
})
}// Save the generated coupons to a coupons.json filelet data = JSON.stringify(output);
fs.writeFileSync('coupons.json', data);
console.log('Done.');
console.log('Check the coupons.json file.');};const runMain = async () => {
try {
await main();
process.exit(0);
} catch (error) {
console.log(error);
process.exit(1);
}
};runMain();

First make sure that you populate your .env file with the private key of the wallet that will be used for signing. Then, populate the addresses array in the script with a list of wallet addresses.

Run node generateCoupons.js to generate and save your coupons to a coupons.json file. Done!

Retrieval

Since each coupon is only valid for a single wallet address, there is no risk if the coupons are exposed. However, for the sake of keeping the whitelist private, it’s still a good idea to hide it behind an API endpoint that responds to a wallet address and returns the corresponding coupon if found.

While writing an API to serve these coupons is beyond the scope of this article, I can show you how easy it would be to use the below code to find and return the right coupon:

// retrieve the wallet from the query wallet
const wallet = req.query.wallet// Find a coupon for the passed wallet address
const c = coupons.filter(coupon => coupon.wallet.toLowerCase() === wallet.toLowerCase())if (0 == c.length) {
return res.status(200).json({
coupon: null,
message: 'Coupon not found'
})
}return res.status(200).json({coupon: c[0]})

💡 The Next.js framework is an excellent choice to build this API and the remaining front-end minting website.

Consumption

In our smart contract, let’s start by defining a struct to represent our coupon.

struct Coupon {
bytes32 r;
bytes32 s;
uint8 v;
}

You might notice that this already looks like the coupon we generated with Javascript.

In our smart contract, we need to do a couple of things to verify that the coupon is valid.

  1. Create the same message digest (containing the wallet address) that we created in our Javascript code.
  2. Use that message digest to recover the signer of our coupon.
  3. Ensure that the recovered signer is in fact, us.

In Solidity, we can achieve this by writing two internal functions:

// Recover the original signer by using the message digest and
// the passed in coupon, to then confirm that the original
// signer is in fact the _couponSigner set on this contract.function _isVerifiedCoupon(bytes32 digest, Coupon memory coupon)
internal
view
returns (bool)
{
address signer = ecrecover(digest, coupon.v, coupon.r, coupon.s);
require(signer != address(0), "ECDSA: invalid signature");
return signer == _couponSigner;
}// Create the same message digest that we know the coupon created
// in our JavaScript code has created.function _createMessageDigest(address _address)
internal
pure
returns (bytes32)
{
return keccak256(
abi.encodePacked(
"\x19Ethereum Signed Message:\n32",
keccak256(abi.encodePacked(_address))
)
);
}

Then we can update our minting function to use our new coupon system:

function mint(Coupon memory coupon)
external
payable
{ require(
_isVerifiedCoupon(_createMessageDigest(msg.sender), coupon),
"Coupon is not valid."
); // require that each wallet can only mint one token
require(
!_mintedAddresses[msg.sender],
"Wallet has already minted."
); // Keep track of the fact that this wallet has minted a token
_mintedAddresses[msg.sender] = true; // ...
}

And there we have it! It’s important to keep track of the wallets that have minted in order to prevent coupons from being reused.

On the minting website we need to pass our coupon when calling the mint function:

async function fetchCoupon(wallet) {
const res = await fetch(`/api/coupons?wallet=${wallet}`) return await res.json()
}async function mint(wallet) {
const coupon = await fetchCoupon(wallet) let tx = await contract.mint(coupon) // ...}

Conclusion

You’ve learnt a simple, secure and effective method to implement an NFT whitelist.

This article is a specially re-written extract from my upcoming book launch: “A developer’s guide to launching an NFT collection”.

Follow me on twitter for more blockchain-related tips and tricks, and to keep in the loop about the book!