Writing the smart contracts

Developer tooling

Developing smart contracts has some new concepts and tools that you'll need to set up and become familiar with.

Since smart contract code needs to be compiled and deployed to a blockchain to be accessed, test driven development proves to be a quicker way to quickly write and test custom code without having to go through the deploy process.

Given the nature of smart contracts I would always recommend writing tests for any custom smart contract code written.

Typically, this is the development process

  • Write tests and smart contract code
  • Deploy smart contracts to a local blockchain
  • Build prototype dApp to interact with smart contract on local blockchain
  • Iterate on the above until ready to share with the client
  • Deploy smart contracts to a test blockchain (we'll be using Goerli)
  • Deploy prototype dApp to a staging environment (Heroku or Vercel work well)
  • Share prototype dApp with client
  • Deploy smart contracts to Ethereum blockchain

Luckily, the Hardhat framework and ecosystem is there to help with all parts of this process. To get started, let's create a new hardhat project that comes with everything you need to get started writing the smart contracts.

Getting started

Let's start by creating a new hardhat project.

You can reference the Hardhat installation guide for more information, but following the steps below will suffice:

  • mkdir nft-collection && cd nft-collection -- Create a home for this project
  • npm init -y -- Create a new project
  • npm install --save-dev hardhat -- Install hardhat (v2.10.1 as of time of writing)
  • npx hardhat -- Create a 'Javascript Project' and install all additional dependencies
  • npx hardhat test -- Optionally run the demo tests to see that hardhat is working correctly
  • Delete the demo contract and test files in the contracts and test folders respectively.
  • Delete the scripts/deploy.js file
  • Create a new test file (test/NFTCollection.js)
  • Create a new smart contract (contracts/NFTCollection.sol)
  • npm install @openzeppelin/contracts dotenv -- Install the OpenZeppelin smart contracts library, and the dotenv library

Boilerplate Smart Contract code

Your NFTCollection.sol file is where all the magic will happen. Copy the below code where we're setting up a basic NFT Collection smart contract.

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.9;

import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/access/Ownable.sol";

contract NFTCollection is ERC721, Ownable {

    // State variables

    // Constructor
    constructor() ERC721 ("Collection Name", "SYMBOL") {
        // ...
    }

    // External functions

    // External functions that are view

    // External functions that are pure

    // Public functions

    // Internal functions

    // Private functions

}

Thanks to the folks at OpenZeppelin most of the heavy lifting is done for us simply by importing from their open-source libraries.

The ERC721 class that we're extending implements all the methods that make our smart contract an NFT collection while the Ownable class gives us some useful access-control functionality that we'll explore later.

I've left some comments that will act as guides for where to place code as our smart contract grows in size.

Preparing the test and test cycle

Let's prepare our NFTCollection.js test file so that we can start writing our first tests.

We'll be using the Mocha testing framework with the Chai assertion library to build out our test cases for the NFTCollection smart contract.

Below is some boilerplate code that you can copy to get started:

const { expect } = require("chai");
const { ethers } = require("hardhat");

describe("NFT Collection contract", function () {

    let contract,
        owner,
        account1,
        account2;

    beforeEach(async function() {
        // ...
    })

    describe("Minting", function () {
        describe("Coupon whitelist", function () {
            // ..
        })
    })
})

The beforeEach function is run before every test and is a useful place to write some common code, like deploying our smart contract, that will otherwise get repetative at the start of each test.

Minting | Coupon whitelist

Let's jump right in by writing some of the core functionality of the smart contract: allowing users to mint an NFT from the collection if they are on the whitelist.

There are currently a few common approaches to implementing a whitelist:

  1. Saving a list of wallet addresses on the smart contract
  2. 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
  3. Using a cryptographic mechanism called a Merkle Tree

Option 1 is expensive for the contract owner, since storing and updating all the wallet addresses on-chain will result in high gas fees.

Options 2 and 3 are both valid but I've chosen to implement option 2: one of the reasons I've chosen this is that we don't need to interact with the smart contract if we want to add or remove wallet addresses from the whitelist - since all of that can happen off-chain.

Here's how our coupons will work:

  • A piece of data is signed off-chain using a private key that’s only known to us.
  • The signature can be recovered on-chain using the matching public key set in our smart contract, allowing us to prove that the data being received was sent by us.
  • In our case, the data that we're including in the coupon is the wallet address that it's valid for, and the type of "Sale" that the coupon is valid for: mint or claim.

Let's start by generating the coupon off-chain.

In the scripts folder, create a new file called coupons.js and paste the below code.

const { ethers } = require('ethers');

// The types of coupons that can be created
const couponTypes = {
  Mint: 0,
  Claim: 1
}

// A function to create a coupon
async function createCoupon(address, couponType, 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 and coupon type in a way
  // that can be signed and later recovered from the smart contract.
  // Hashing the address and type together using the SHA-256 hashing
  // algorithm is a good way to do this.
  const message = ethers.utils.solidityKeccak256(['uint8', 'address'], [couponType, 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,
  couponTypes
}

The code comments explain what is going on, but in a nutshell: we're creating a javascript module that exposes a createCoupon function that accepts a wallet address, coupon type and private key and returns a cryptographically generated coupon that encodes the wallet address and coupon type with the passed private key.

Back in our NFTCollection.js test file, import our new module (const { createCoupon, couponTypes } = require("../scripts/coupons"); at the top of the file) and let's get ready to write our first automated test!

Hardhat abstracts away a lot of the complexities of testing smart contracts (like compiling our smart contracts, sparking up a blockchain node on your computer and creating test accounts for you to use as part of your tests - all automatically!).

Since we'll need an instance of our smart contract to test against for every test, let's populate the beforeEach function (that will run before every test) with some code that will do that:

// The `getSigners` method returns 20 test wallets that are created on the blockchain node automatically with some test ether in them.
// By default, the first account returned is used when sending transactions - and will become the owner of the deployed contract. We'll save it and a few other accounts as named variables for convenience later on.
[owner, account1, account2] = await ethers.getSigners();

// Get a factory instance that will allow us to deploy our smart contract
const factory = await hre.ethers.getContractFactory('NFTCollection');

// Deploy our smart contract
contract = await factory.deploy();

// Wait for the smart contract to be deployed
await contract.deployed();

Now that we have some boiler plate code to make our tests easier to write, we can add our first test to the "Coupon whitelist" block:

it("should allow someone with a coupon to mint a token", async function() {

    // Create a new key-pair that we'll use as our "coupon signer"
    let { privateKey, address } = ethers.Wallet.createRandom()

    // Let the contract know the public address of our "coupon signer"
    await contract.setCouponSigner(address);

    // Create a coupon for account1 with our "coupon signer"
    let coupon = await createCoupon(account1.address, couponTypes.Mint, privateKey)

    // The Zero Address is a special address that is used to represent a null address.
    let zeroAddress = ethers.constants.AddressZero;

    // Connect to the contract as account1 and mint a token
    // Assert that the newly created token was transferred to account1
    await expect(contract.connect(account1).mint(coupon))
        .to.emit(contract, "Transfer", zeroAddress, account1.address, 0);
})

Let's run our tests by typing npx hardhat test in the terminal. They should fail by saying contract.setCouponSigner is not a function. Let's start there.

We want to store the public address of the "coupon signer" on the contract so that it can verify that our coupon was created by us.

Let's add a new state variable to store the address in our smart contract under the "State variables" section:

contract NFTCollection is ERC721, Ownable {

    // State variables

    address private _couponSigner;
    // ...

And create a function to allow us to set the coupon signer under the "External functions" section:

// External functions

function setCouponSigner(address couponSigner)
  external
  onlyOwner
{
  _couponSigner = couponSigner;
}

You'll notice a couple of modifiers on the setCouponSigner function: external and onlyOwner.

The external modifier is offered by Solidity itself and marks our function as one that can only be called by outside of the smart contract itself.

The onlyOwner modifier is functionality we imported from the OpenZeppelin library at the top of the file, and acts as an access control mechanism that requires the external caller of this function to be the owner.

If we re-run our tests now, we should be presented with an error complaining that mint is not a function. Great!

We'll start by creating a couple of state variables to represent our coupon and coupon types. These will look similar to our javascript code that we wrote earlier.

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

enum CouponType {
    Mint,
    Claim
}

Let's add our mint function under the "External functions" section:

function mint(Coupon memory coupon)
    external
    payable
{
    uint mintIndex = _mintIndex;

    _safeMint(msg.sender, mintIndex);

    _mintIndex++;
}

You'll notice a new state variable _mintIndex being referenced. We'll use this as a simple mechanism to keep track of the number of NFTs that have been created. Your code editor should be yelping at you since _mintIndex is undefined. Let's fix that by declaring it in our "State variables" section up top:

uint private _mintIndex;

If we run our tests now we should see green! Though a little cheeky (we've "slimed" the test to pass without actually requiring a coupon) it's actually a good practice, since it reveals that our test cases are not comprehensive enough: In the world of smart contracts, where small bugs can be extremely costly, having good tests is extremely important.

Let's add another test case:

it("should prevent someone with an invalid coupon to mint a token", async function() {

    // Create a new key-pair that we'll use as our "coupon signer"
    let { privateKey, address } = ethers.Wallet.createRandom()

    // Let the contract know the public address of our "coupon signer"
    await contract.setCouponSigner(address);

    // Create a coupon for account1 with our "coupon signer"
    let coupon = await createCoupon(account1.address, couponTypes.Mint, privateKey)

    // Connect to the contract as account1 and attempt to mint a token using account2's coupon
    await expect(contract.connect(account1).mint(coupon)).to.be.revertedWith("Coupon is not valid.");
})

Now if we run our tests we'll be met by failure - this time becuase it actually is possible to use someone else's coupon. It's not so easy to slime this test while keeping the previous one passing, so let's build out the actual solution now.

Let's start by creating a couple of helper functions under the "Internal Functions" section:

// 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(CouponType _type, address _address)
    internal
    pure
    returns (bytes32)
{
    return keccak256(
        abi.encodePacked(
            "\x19Ethereum Signed Message:\n32",
            keccak256(abi.encodePacked(_type, _address))
        )
    );
}

With the above, it's become easy to add a line of code to the top of our mint function that will make our tests pass.

// require a valid coupon
require(
    _isVerifiedCoupon(_createMessageDigest(CouponType.Mint, msg.sender), coupon), 
    "Coupon is not valid."
);

The require function is a very important feature of Solidity that you'll use a lot as a smart contract developer: it allows you to ensure that the transaction is processed only if certain conditions are met. Here we're saying: if something's wrong with the coupon then bail.

Phew! That was a lot. But our mint function is not production ready yet.

Only allowing one mint per wallet

Right now, anyone with a valid coupon can mint as many times as they want, and in our use case we want to limit it to "one mint per wallet".

Let's start by creating a failing test case.

it("should prevent someone from minting more than one token", async function() {

    // Create a new key-pair that we'll use as our "coupon signer"
    let { privateKey, address } = ethers.Wallet.createRandom()

    // Let the contract know the public address of our "coupon signer"
    await contract.setCouponSigner(address);

    // Create a coupon for account2 with our "coupon signer"
    let coupon = await createCoupon(account2.address, couponTypes.Mint, privateKey)

    // Successfully mint a token
    await contract.connect(account1).mint(coupon);

    // Require that each coupon only be used once
    await expect(contract.connect(account1).mint(coupon)).to.be.revertedWith("Wallet has already minted.");
})

In order to keep track of who has minted a token, let's create a new state variable up top:

mapping(address => bool) private _mintedAddresses;

Solidity allows us to create a "dictionary" or "hash table" using the mapping variable type that allows us to easily keep track of which wallet address has minted.

We'll add some more logic to our mint function:

  • requiring that a wallet that has already minted is prevented from minting again
  • adding the current user's wallet to the list of minted addresses
function mint(Coupon memory coupon)
    external
{
    // require a valid coupon
    require(
        _isVerifiedCoupon(_createMessageDigest(CouponType.Mint, 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;

    uint mintIndex = _mintIndex;

    _safeMint(msg.sender, mintIndex);

    _mintIndex++;
}

With that done, our tests will now pass again and we've plugged that loophole!

Accepting payment for the mint

Though "free mints" are a thing (and can still be profitable due to secondary sales) - our use case is the slightly more sophisticated "paid mint".

To keep things organised, we can add another describe block to our test cases

describe("Sale price", function () {
    it("should prevent a sender from minting if they pay less than the minting fee", async function() {
        // Set up
        let {privateKey, address} = ethers.Wallet.createRandom()

        await contract.setCouponSigner(address);

        let coupon = await createCoupon(account1.address, couponTypes.Mint, privateKey)

        // Prevent a wallet from minting if the price paid is too low
        await expect(contract.connect(account1).mint(coupon, {
           value: 1 
        })).to.be.revertedWith("Minting price must be paid.");
    })
})

You'll notice that we're passing in a second parameter when calling the mint function with the value of ether (denominated in gwei) that we are sending to the smart contract.

While I've hardcoded the value to "1" over here, you can update this to be a value just lower than your minting price.

It's a common practice to set the minting price as a constant in the smart contract - proving to those who care to check that the price simply cannot be changed.

For instance, at the top of your contract:

uint256 public constant TOKEN_PRICE = 0.01 ether;

Doing the above will make it possible to update your tests to use the actual token price

let value = await contract.TOKEN_PRICE()

await expect(contract.connect(account1).mint(coupon, { value.sub(1) }))
    .to.be.revertedWith("Minting price must be paid.");

Then we'll need to update our mint function to include the payable modifier that enables this function can receive funds when being called. In combination with another require statement to ensure that we're receiving the right amount of funds we should be able to make our new test pass.

function mint(Coupon memory coupon)
    external
    payable
{
    // require that the sender pay the minting price
    require(msg.value >= TOKEN_PRICE, "Minting price must be paid.");

    // ...

Unfortunately all of our other tests will now be failing. Whoops.

Before moving on from this section, make sure that you make all your tests green by updating wherever the mint function is called to pass in the correct token price. Since we'll need to the token value quite often, we can retrieve it once in our beforeEach hook and then reference the value throughout our tests!

Starting and stopping the sale

While it's possible to control when the sale starts by choosing when to release the coupons - it's always good to have a failsafe to quickly start and stop the sale via the smart contract.

As usual, we'll start with our tests. I'll populate a few in one go to save time:

describe("Sale", function () {
    it("should allow contract deployer to enable the sale", async function() {

        let saleIsActive = await contract.isSaleActive()

        expect(saleIsActive).to.equal(false);

        await contract.setSaleState(true);

        saleIsActive = await contract.isSaleActive()

        expect(saleIsActive).to.equal(true);
    })

    it("should allow contract deployer to disable the sale", async function() {

        await contract.setSaleState(true);

        let saleIsActive = await contract.isSaleActive()

        expect(saleIsActive).to.equal(true);

        await contract.setSaleState(false);

        saleIsActive = await contract.isSaleActive()

        expect(saleIsActive).to.equal(false);
    })

    it("should prevent non-deployers from enabling the sale", async function() {

        const [deployer, account1] = await ethers.getSigners();

        await expect(contract.connect(account1).setSaleState(true))
            .to.be.revertedWith("Ownable: caller is not the owner");
    })

    it("should prevent minting when the sale is not active", async function() {

        const [sender] = await ethers.getSigners();

        let {privateKey, address} = ethers.Wallet.createRandom()

        await contract.setCouponSigner(address);

        let coupon = await createCoupon(sender.address, couponTypes.Mint, privateKey)

        await expect(contract.mint(coupon))
            .to.be.revertedWith("Sale is not active.");
    })
})

Making our tests pass should be straightforward to us now - as we don't have to introduce any new concepts from the ones learnt so far.

Let's start by adding a state variable to store whether the sale is active:

bool private _saleIsActive = false;

And then create our getter and setter:

function isSaleActive()
    external
    view
    returns (bool)
{
    return _saleIsActive;
}

function setSaleState(bool saleIsActive)
  external
  onlyOwner
{
  _saleIsActive = saleIsActive;
}

Now that we can control our sale state, we still have one test to make pass: it "should prevent minting when the sale is not active".

Let's add in one more line to our mint function to make this test pass and a whole bunch of our previous tests fail:

// require that sale is active
require(_saleIsActive, "Sale is not active.");

You should now spend a few minutes tweaking your previous tests to ensure that we're enabling the sale before trying to mint and only move on once they're back to green.

Limiting the supply of the NFT

It's important that the supply of the NFT be limited in the smart contract to prove to the public that no new tokens can be minted later on.

Adding a constant to the top of the file is a perfectly reasonable approach here, however I've found that assigning the "total supply" in the constructor makes it easier to write tests to ensure that the total supply is never surpassed (which would be a very slow test if we have to first mint 10,000 tokens!).

We can also take this opportunity to kill two birds with one stone and create the concept of "reserved tokens" that will be the number of tokens that can't be minted and can only be given away for free. This is a common practice to either reserve some NFTs for the creating team - or to give some away as part of a marketing effort.

Let's write the test first:

it("should prevent more tokens than the totalSupply from being minted", async function() {
    // deploy new version of the contract with a smaller total supply 
    // to make it easier and faster to test.
    const totalSupply = 20;
    const reservedTokens = 15;
    const factory = await hre.ethers.getContractFactory('NFTCollection');
    contract = await factory.deploy(totalSupply, reservedTokens);

    await contract.deployed();

    // Set the coupon signer to a new address
    let {privateKey, address} = ethers.Wallet.createRandom()
    await contract.setCouponSigner(address);

    // Enable the sale
    await contract.setSaleState(true);

    // Get our 20 accounts
    let accounts = await ethers.getSigners()

    // Calculate the amount of tokens that are available to mint after we 
    // subtract the amount that are reserved for airdropping / claiming
    const availableToMint = totalSupply - reservedTokens;

    const tokenPrice = await contract.TOKEN_PRICE()

    // Mint the amount of tokens that are available to mint
    for (let i = 0; i < availableToMint; i++) {
        let coupon = await createCoupon(accounts[i].address, couponTypes.Mint, privateKey)
        await contract.connect(accounts[i]).mint(coupon, {
            value: tokenPrice
        })
    }

    // Finally, try to mint one more token. This should fail.
    const [sender] = await ethers.getSigners();
    coupon = await createCoupon(sender.address, couponTypes.Mint, privateKey)

    await expect(contract.mint(coupon))
        .to.be.revertedWith("Minting limit reached.");
})

Let's start by creating our state variables:

uint16 private immutable _totalSupply;
uint16 private immutable _reservedTokens;

The new immutable keyword allows us to instantiate these variables in the constructor but prevents them from ever being updated again. This is good for us - since it easily proves to the public that there's no way for us to change these numbers once the contract has been deployed.

We must update our constructor to accept and assign these new values:

constructor(uint16 totalSupply, uint16 reservedTokens) ERC721 ("Collection Name", "SYMBOL") {
    _totalSupply = totalSupply;
    _reservedTokens = reservedTokens;
}

Our new tests will still fail since we aren't yet preventing people from minting more than the maximum supply of tokens available.

Adding a require statement to the top of the mint function will satisfy the new test conditions:

// require that the _totalSupply - RESERVED_TOKENS has not been reached
require(_mintIndex < (_totalSupply - _reservedTokens), "Minting limit reached.");

All of our tests should be failing because we've updated the contstructor definition - luckily the fix is straightforward: don't move on from this section until you've fixed the deploy code in the beforeEach block at the top of our tests.

Airdropping and claiming

Airdropping is a mechanism for allowing an authorized user to send some NFTs for free. These can be NFTs reserved for the internal team, or given away as prizes or used for marketing purposes.

It's possible to create an airdrop function that the contract owner can call to send NFTs to a list of wallet addresses - in fact, we're going to do that below, but a common question that comes up from clients is how to accomplish this without incurring a gas cost for the contract owner.

Apart from implementing the airdrop functionality, we'll also implement a claim function (think of this like a free mint) where people who are in possession of a "claim" coupon can claim an NFT from this collection. In this way, the gas costs are offloaded to the person performing the claim.

Let's start with the "claim" funcitonality. This is all going to look very familiar to the "mint" functionality - it would be a good idea to try and develop this solution for yourself without copy/pasting the code and then coming back here to see if your solution matches up.

Let's start by writing some tests to toggle a new "claim state" that will control whether users can claim or not.

describe('Airdrop | Set claim state', function () {
    it("should allow the deployer to set the airdrop state", async function() {

        const [deployer] = await ethers.getSigners();

        await contract.connect(deployer).setClaimState(true);
    })

    it("should prevent non-deployers from setting the airdrop state", async function() {

        const [deployer, account1] = await ethers.getSigners();

        await expect(contract.connect(account1).setClaimState(true))
            .to.be.revertedWith("Ownable: caller is not the owner");
    })
})

Let's make these tests pass. At the top of he contract:

bool public _claimIsActive = false;

And then at the bottom of our external functions block:

function setClaimState(bool claimIsActive)
  external
  onlyOwner
{
  _claimIsActive = claimIsActive;
}

With that done, let's write the tests for our claim function.

describe('Airdrop | Claim', function () {
    it("should prevent someone from claiming a token if the airdrop hasn't started", async function() {
        const [deployer, account1] = await ethers.getSigners();

        let {privateKey, address} = ethers.Wallet.createRandom()

        await contract.connect(deployer).setCouponSigner(address);

        let coupon = await createCoupon(account1.address, couponTypes.Claim, privateKey)

        await expect(contract.connect(account1).claim(coupon))
            .to.be.revertedWith("Claim is not active.");
    })

    it("should allow someone to claim a token if they have a valid coupon", async function() {

        await contract.setClaimState(true);

        const [deployer, account1] = await ethers.getSigners();

        let {privateKey, address} = ethers.Wallet.createRandom()

        await contract.connect(deployer).setCouponSigner(address);

        let coupon = await createCoupon(account1.address, couponTypes.Claim, privateKey)

        await contract.connect(account1).claim(coupon);
    })

    it("should prevent someone from claiming a token with someone elses coupon", async function() {

        await contract.setClaimState(true);

        const [deployer, account1, account2] = await ethers.getSigners();

        let {privateKey, address} = ethers.Wallet.createRandom()

        await contract.connect(deployer).setCouponSigner(address);

        let coupon = await createCoupon(account2.address, couponTypes.Claim, privateKey)

        await expect(contract.connect(account1).claim(coupon))
            .to.be.revertedWith("Coupon is not valid.");
    })

    it("should prevent someone from claiming more than one token", async function() {

        await contract.setClaimState(true);

        const [deployer, account1, account2] = await ethers.getSigners();

        let {privateKey, address} = ethers.Wallet.createRandom()

        await contract.connect(deployer).setCouponSigner(address);

        let coupon = await createCoupon(account1.address, couponTypes.Claim, privateKey)

        await contract.connect(account1).claim(coupon);

        await expect(contract.connect(account1).claim(coupon))
            .to.be.revertedWith("Wallet has already claimed.");
    })
})

These tests should look really familiar to the tests for the mint function - except we don't test to ensure that the user is paying a fee for the token (which is free to mint).

Let's implement this new functionality in our smart contract. Up at the top of the contract:

uint8 private _claimedSupply = 0;
mapping(address => bool) private _claimedAddresses;

And in our external functions block:

function claim(Coupon memory coupon)
  external
{
  // require that claim is active
  require(_claimIsActive, "Claim is not active.");

  // require that we don't exceed the reserved supply
  require(
    _claimedSupply < _reservedTokens,
    "Token reserve has run out."
  );

  // require a valid coupon
  require(
    _isVerifiedCoupon(_createMessageDigest(CouponType.Claim, msg.sender), coupon), 
    "Coupon is not valid."
  );

  // require that each wallet can only claim one token
  require(
    !_claimedAddresses[msg.sender],
    "Wallet has already claimed."
  );

  _claimedAddresses[msg.sender] = true;

  uint mintIndex = _mintIndex;

  _safeMint(msg.sender, mintIndex);

  _claimedSupply++;
}

And we're done! The same mechanism that we used for coupon generation for the mints will work here for the claims, we just need to remember to change the coupon type from "Mint" to "Claim" in our smart contract (above) and when we're creating our production coupons (which we'll do later).

Now that our contract offers the claim ability, let's add the traditional "airdrop" functionality to allow a contract owner to "send" tokens to a list of addresses.

describe("Airdrop | Send tokens", function () {
    it("should allow a contract deployer to airdrop tokens to multiple wallets", async function() {
        const [deployer, account1, account2] = await ethers.getSigners();

        let zeroAddress = ethers.constants.AddressZero;

        await expect(contract.airdrop([account1.address, account2.address]))
            .to.emit(contract, "Transfer").withArgs(zeroAddress, account1.address, 0)
            .to.emit(contract, "Transfer").withArgs(zeroAddress, account2.address, 1)
    })

    it("should prevent airdropping to someone who has already been airdropped a token", async function() {
        const [deployer, account1, account2] = await ethers.getSigners();

        await contract.airdrop([account1.address]);

        await expect(contract.airdrop([account1.address]))
            .to.be.revertedWith("Wallet has already claimed.");
    })

    it("should prevent airdropping to someone who has already claimed a free token", async function() {
        await contract.setClaimState(true);

        const [deployer, account1, account2] = await ethers.getSigners();

        let {privateKey, address} = ethers.Wallet.createRandom()

        await contract.connect(deployer).setCouponSigner(address);

        let coupon = await createCoupon(account1.address, couponTypes.Claim, privateKey)

        await contract.connect(account1).claim(coupon);

        await expect(contract.airdrop([account1.address]))
            .to.be.revertedWith("Wallet has already claimed.");
    })

    it("should prevent airdropping more than the reserved amount of tokens", async function() {
        const totalSupply = 300;
        const reservedTokens = 10;
        const factory = await hre.ethers.getContractFactory('NFTCollection');
        contract = await factory.deploy(totalSupply, reservedTokens);

        await contract.deployed();

        const accounts = await ethers.getSigners();

        let addresses = accounts.splice(0, reservedTokens+1).map(account => account.address);

        await expect(contract.airdrop(addresses))
            .to.be.revertedWith("Can't airdrop that many tokens.")
    })

    it("should prevent airdropping more than the remaining reserve of tokens", async function() {
        const totalSupply = 300;
        const reservedTokens = 10;
        const factory = await hre.ethers.getContractFactory('NFTCollection');
        contract = await factory.deploy(totalSupply, reservedTokens);

        await contract.deployed();

        const accounts = await ethers.getSigners();

        let addresses = accounts.splice(0, reservedTokens).map(account => account.address);
        let extraAddresses = accounts.slice(-1).map(account => account.address);

        await contract.airdrop(addresses);

        await expect(contract.airdrop(extraAddresses))
            .to.be.revertedWith("Can't airdrop that many tokens.")
    })
})

And here's the code that makes it pass.

function airdrop(address[] memory _addresses)
  external
  onlyOwner
{
  // require that we don't exceed the reserved supply
  require(
    _claimedSupply + _addresses.length <= _reservedTokens,
    "Can't airdrop that many tokens."
  );

  for (uint i = 0; i < _addresses.length; i++) {
    // require that each wallet can only claim one token
    require(
      !_claimedAddresses[_addresses[i]],
      "Wallet has already claimed."
    );

    uint mintIndex = _mintIndex;

    _claimedAddresses[_addresses[i]] = true;

    _safeMint(_addresses[i], mintIndex);

    _mintIndex++;
  }

  _claimedSupply = _claimedSupply + uint8(_addresses.length);
}

For sanity's sake, we should add one more test to our "claiming" test block:

it("should prevent someone from claiming with a valid coupon if the reserve has all been airdropped", async function() {
    const totalSupply = 300;
    const reservedTokens = 10;
    const factory = await hre.ethers.getContractFactory('NFTCollection');
    contract = await factory.deploy(totalSupply, reservedTokens);

    await contract.setClaimState(true);

    const reservedTokens = await contract.RESERVED_TOKENS();

    const [deployer, ...accounts] = await ethers.getSigners();

    let addresses = accounts.splice(0, reservedTokens).map(account => account.address);

    await contract.airdrop(addresses);

    let {privateKey, address} = ethers.Wallet.createRandom()

    await contract.connect(deployer).setCouponSigner(address);

    let coupon = await createCoupon(accounts[accounts.length-1].address, couponTypes.Claim, privateKey)

    await expect(contract.connect(accounts[accounts.length-1]).claim(coupon))
        .to.be.revertedWith("Token reserve has run out.");
})

Withdrawing funds from the contract

We'd hate for our client to have a wildly successful drop and then for the funds to be stuck in the contract with no way to withdraw them!

It's quick to add this functionality.

We can quickly write a couple of tests first:

describe("Withdrawing funds", function () {
    it("should allow the deployer to withdraw funds", async function() {

        const [deployer, account1] = await ethers.getSigners();

        await contract.connect(deployer).withdraw();
    })

    it("should prevent non-deployers from withdrawing funds", async function() {

        const [deployer, account1] = await ethers.getSigners();

        await expect(contract.connect(account1).withdraw())
            .to.be.revertedWith("Ownable: caller is not the owner");
    })
})

And then add a new function to our smart contract in the "External functions" section:

function withdraw()
  external
  onlyOwner
{
  payable(msg.sender).transfer(address(this).balance);
}

Token reveal mechanism

Before starting to build this feature, let's expand on our business requirements:

  • Hide the tokens and metadata until all tokens have been minted
  • Send a "pre-reveal" version of the token to users once they mint
  • Allow the contract owner to "reveal" all tokens in the collection

Our smart contract is responsible for returning the URL where each token's metadata.json file lives.

The way we can solve for the above is to create a "pre-reveal" metadata file and upload it somewhere (e.g.: https://example.org/pre-reveal.json). This "pre-reveal URI" is what we want to return for every token until the reveal takes place.

After the reveal, we want to be able to update the smart contract with a new URL that can be used to generate the correct token URIs. For example, if we've uploaded all our tokens to a webserver that responds to https://exmaple.org/tokens/:tokenId, then we'll want to update the smart contract with our baseURI (https://example.org/tokens/) so that it can easily generate the correct tokenURI by simply appending the token ID to the baseURI.

With this knowledge, we can redefine the problem to more concrete asks of our smart contract:

  • It should return a generic metadata when the collection is not yet revealed
  • It should allow the contract owner to update the baseURI
  • It should return the correct metadata when the token is revealed

That's starting to look more like something we can write some tests for!

describe("Token reveal", function () {
    it("should return a generic metadata when the collection is not yet revealed", async function () {
        await contract.setSaleState(true);

        let { privateKey, address } = ethers.Wallet.createRandom()
        await contract.setCouponSigner(address);

        let coupon = await createCoupon(account1.address, couponTypes.Mint, privateKey)

        const tokenPrice = await contract.TOKEN_PRICE()
        await contract.connect(account1).mint(coupon, {
            value: tokenPrice
        })

        let tokenUri = await contract.tokenURI(0)

        expect(tokenUri).to.equal(preRevealUri);
    });
})

In our test I'm assuming that the preRevealUri is already set in the test file and in the smart contract. We haven't actually done that yet - let's first revisit our beforeEach function at the top of the test file and update our code to set a preRevealUri and pass it to our smart contract constructor.

Let's define another variable above our beforeEach function:

let contract,
        owner,
        account1,
        account2,
        preRevealUri = 'ipfs://PRE_REVEAL'

...and in our beforeEach function let's update the line that deploys the contract...

contract = await factory.deploy(totalSupply, reservedTokens, preRevealUri);

Our failing tests should be pointing us in the right direction now. Let's first update our smart contract constructor to accept the preRevealUri and store it as a state variable.

// Metadata URI of our pre reveal token
string private _preRevealURI;

// Base URI for after the reveal
string private _postRevealBaseURI;

// Has this collection been revealed yet?
bool private _isRevealed = false;

//...

constructor(uint16 totalSupply, uint16 reservedTokens, string memory preRevealURI)
    ERC721 ("Collection Name", "SYMBOL") 
{
    _totalSupply = totalSupply;
    _reservedTokens = reservedTokens;
    _preRevealURI = preRevealURI;
}

Even though we haven't defined a tokenURI function, you'll find that it is implemented for us by OpenZeppelin's ERC721.sol contract that we are extending our contract from.

Here it is, for reference:

// OpenZeppelin's implementation of `tokenURI`
function tokenURI(uint256 tokenId) public view virtual override returns (string memory) {
    require(_exists(tokenId), "ERC721Metadata: URI query for nonexistent token");

    string memory baseURI = _baseURI();
    return bytes(baseURI).length > 0 ? string(abi.encodePacked(baseURI, tokenId.toString())) : "";
}

Their implementation (above) will automatically concatenate the base URI (returned from _baseURI() - which we'll need to remember for later) with the token ID being retrieved. This is great for AFTER the reveal - but for the pre-reveal we'll need to override this function to always return our _preRevealURI:

function tokenURI(uint256 tokenId)
  public
  view
  override
  returns (string memory)
{
  if (!_isRevealed) {
    return _preRevealURI;
  }

  return super.tokenURI(tokenId);
}

Hey - that works! Our new tests should be passing - don't move past this section until all previous tests that we might have broken with our updates are passing too.

Let's add a new test to our "Token reveal" block to describe our post-reveal scenario.

it("should return the correct metadata when the collection is revealed", async function () {
    await contract.setSaleState(true);

    let { privateKey, address } = ethers.Wallet.createRandom()
    await contract.setCouponSigner(address);

    let coupon = await createCoupon(account1.address, couponTypes.Mint, privateKey)

    const tokenPrice = await contract.TOKEN_PRICE()
    await contract.connect(account1).mint(coupon, {
        value: tokenPrice
    })

    contract.reveal("ipfs://REVEAL/");

    let tokenUri = await contract.tokenURI(0)

    expect(tokenUri).to.equal("ipfs://REVEAL/0");
});

Our tests will error out because we haven't implemented our reveal method on our smart contract yet. Let's do that now:

function reveal(string memory baseURI)
  external
  onlyOwner
{
  _postRevealBaseURI = baseURI;
  _isRevealed = true;
}

Our tests will now fail because the tokenURI returned by the smart contract is not correct. Remember that OpenZeppelin's tokenURI implementation will concatenate the baseURI with the tokenID for us, however we need to override their _baseURI() method in order to return our _postRevealBaseURI.

/// @dev Override _baseURI
function _baseURI()
  internal
  view
  override
  returns (string memory)
{
  return _postRevealBaseURI;
}

That was simple enough. Our tests are now passing and we have a working reveal mechanism!

Provenance hash

By introducing our token reveal mechanism we've introduced a new problem that I'll expand on here.

Since tokens will be minted blind - there's a period of time between when minting ends and when the tokens are revealed where the token order can be adjusted by the contract owners. The risk here is that since the token owners will be known, it's possible to adjust the token IDs so that certain known wallets are awarded favourable NFTs from the collection being released.

We can use cryptography to prove two things: one, that the order of the images was determined before the sale started, and two, that the images in the NFT collection can never be tampered with.

The technique is as follows:

  • Create a hash for every image in the NFT collection.
  • Combine the hashes into one long string, in the order that the collection will be released.
  • Hash this one long string. The resulting hash is the "provenance hash".
  • Store the provenance hash in the smart contract in a way that proves that it was calculated before minting started.

I suggest using the SHA-256 hashing function, since it is heavily used in the blockchain world already - and is commonly used when generating provenance hashes.

While building the provenance generation code is out of scope for this book, my forked version of the Hashlips library has some utilities that can help generate the provenance hash for you. Please see the repository for up-to-date instructions.

On the smart contract side, we can see that the technical requirements can be achieved very simply by hard coding the provenance hash as a constant.

bytes32 public constant PROVENANCE_HASH = 0x0000000000000000000000000000000000000000000000000000000000000000;

I've used the bytes32 type instead of string simply because it takes less storage - and in the blockchain world, that results in lower gas fees when deploying!

Of course - using this technique requires you to know the provenance hash before your contract is deployed. You might decide that it's better to implement a setProvenanceHash method on the smart contract to allow the value to be changed after the contract is deployed. Work with the client to decide which approach makes most sense.

results matching ""

    No results matching ""