A very simple smart contract for your NFT collection

a web developer writing code on a computer screen, colorful and futuristic illustration
Image generated by DALL·E 2

In a previous article, we saw how to create an NFT collection with Bueno (with a little help from Figma). It was nice and all but it missed a crucial part: the smart contract. This is what we will deal with in this article.

First of all, you should know that it's possible to create your smart contract directly with Bueno, without writing a single line of code. Nevertheless, I won't use this possibility here because I'm a programmer and I like writing my own stuff (also Bueno takes 5% on all the token sales but it's totally worth it if you're not a programmer).

So, as I'm a programmer and I like writing my own stuff, I will use Hardhat to write my smart contract. Hardhat defines itself as an Ethereum development environment for professionals. It facilitates performing frequent tasks, such as running tests, automatically checking code for mistakes, or interacting with a smart contract.

To initiate a Hardhat project, it's as simple as running the following commands (it is assumed that you have recent versions of Node and npm installed on your machine):

$ mkdir stupidfaces
$ cd stupidfaces
$ npx hardhat

You obviously can replace stupidfaces with the name of your NFT project. I also made the choice to pick TypeScript instead of JavaScript when asked about it but feel free to do as you wish.

After that, we'll also need to install a few dependencies (that I will explain a bit later):

$ npm install @openzeppelin/contracts
$ npm install erc721a

That's the folder organization you should get:

Now we're all set up to write our smart contract.

Writing our smart contract

As we plan to deploy our contract on the Ethereum blockchain, we have no choice but to write our smart contract with the Solidity programming language. The purpose of this article is not to teach you about Solidity (there are fun ways to learn it elsewhere), but you should be fine if you already know programming languages like JavaScript or Python.

Right below is our contract in its entirety (located at contracts/StupidFaces.sol). If it's the first time you see Solidity code, you might be caught off guard a little bit, but don't worry as we'll explain everything, line by line.

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

import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/utils/Strings.sol";
import "erc721a/contracts/ERC721A.sol";

contract StupidFaces is ERC721A, Ownable {
    enum Step {
        Prologue,
        Sale,
        Epilogue
    }

    Step public currentStep;

    string public baseURI;

    uint256 private constant MAX_SUPPLY = 1000;

    uint256 public salePrice = 0.001 ether;
    uint256 public individualLimit = 10;
    uint256 public totalMinted = 0;

    mapping(address => uint256) public amountNFTsPerWallet;

    constructor() ERC721A("Stupid Faces", "STF") {}

    // Mint

    function mint(uint256 _quantity) external payable {
        address addr = msg.sender;
        uint256 price = salePrice;
        require(currentStep == Step.Sale, "Public sale is not active");
        require(
            amountNFTsPerWallet[addr] + _quantity <= individualLimit,
            string(
                abi.encodePacked(
                    "You can only get ",
                    Strings.toString(individualLimit),
                    " NFTs on the public sale"
                )
            )
        );
        require(
            totalMinted + _quantity <= MAX_SUPPLY,
            "Maximum supply exceeded"
        );
        require(msg.value >= price * _quantity, "Not enough funds");
        totalMinted += _quantity;
        amountNFTsPerWallet[addr] += _quantity;
        _safeMint(addr, _quantity);
    }

    function airdrop(address _addr, uint256 _quantity) external onlyOwner {
        require(
            amountNFTsPerWallet[_addr] + _quantity <= individualLimit,
            string(
                abi.encodePacked(
                    "You can only get ",
                    Strings.toString(individualLimit),
                    " NFTs on the public sale"
                )
            )
        );
        require(
            totalMinted + _quantity <= MAX_SUPPLY,
            "Maximum supply exceeded"
        );
        totalMinted += _quantity;
        amountNFTsPerWallet[_addr] += _quantity;
        _safeMint(_addr, _quantity);
    }

    // Utils

    function tokenURI(uint256 _tokenId)
        public
        view
        virtual
        override
        returns (string memory)
    {
        require(_exists(_tokenId), "URI query for nonexistent token");
        return string(abi.encodePacked(baseURI, Strings.toString(_tokenId)));
    }

    // Getters and setters

    function setBaseURI(string memory _baseURI) external onlyOwner {
        require(
            bytes(baseURI).length == 0,
            "You can only set the base URI once"
        );
        baseURI = _baseURI;
    }

    function getBaseURI() public view returns (string memory) {
        return baseURI;
    }

    function setSalePrice(uint256 _salePrice) external onlyOwner {
        salePrice = _salePrice;
    }

    function getSalePrice() public view returns (uint256) {
        return salePrice;
    }

    function setIndividualLimit(uint256 _individualLimit) external onlyOwner {
        individualLimit = _individualLimit;
    }

    function getIndividualLimit() public view returns (uint256) {
        return individualLimit;
    }

    function setCurrentStep(uint256 _currentStep) external onlyOwner {
        require(_currentStep > uint256(currentStep), "You can only go forward");
        currentStep = Step(_currentStep);
    }

    function getCurrentStep() public view returns (uint256) {
        return uint256(currentStep);
    }

    // Withdraw

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

    // Overrides

    function _startTokenId() internal view virtual override returns (uint256) {
        return 1;
    }
}

Let's start by having a look at the very top of the file:

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

import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/utils/Strings.sol";
import "erc721a/contracts/ERC721A.sol";

contract StupidFaces is ERC721A, Ownable {
	// ...
}

The purpose of the first line is to provide a license for our contract. In this case, we choose the MIT license which is a very common open source license (if not the most common), using a machine-readable SPDX license identifier. The second line sets the Solidity version we're using (with the pragma keyword). At the time of writing this article, we were at 0.8.x (stay alert because it changes quickly). Then come all the imports. The first two imports are from OpenZeppelin and the third one is the ERC721A library from Azuki.

Before going any further, let's do a quick (but important) digression on ERC standards. As a Solidity programmer, you could write whatever you want and the way you want but at the same time, you'd want to be interoperable with the entire ecosystem and not reinvent the wheel every time you write a smart contract. This is why the Ethereum community created ERC standards. They're all listed on this page but the most famous ones are ERC-20 for fungible tokens, ERC-721 for non-fungible tokens, and ERC-1155 (a combination of both). End of digression.

In our case, you understood that we need to use the ERC-721 standard. OpenZeppelin, among other things, provides an implementation for the ERC-721 standard, so we don't have to write it ourselves. In this case, we won't use OpenZeppelin's implementation though, but an improved implementation called ERC721A that aims at dramatically reducing the gas fees required to mint NFTs and that's been introduced by the team behind the Azuki NFT project. That said, we still need a few things provided by OpenZeppelin, like the Ownable library (to facilitate access rights to the methods of our contracts) and the Strings library (for string manipulation).

After all the initial declarations and imports come the actual code. We declare that our contract is ERC721A and Ownable (for access control) then we declare a few variables:

// ...

contract StupidFaces is ERC721A, Ownable {
    enum Step {
        Prologue,
        Sale,
        Epilogue
    }

    Step public currentStep;

    string public baseURI;

    uint256 private constant MAX_SUPPLY = 1000;

    uint256 public salePrice = 0.001 ether;
    uint256 public individualLimit = 10;
    uint256 public totalMinted = 0;

    mapping(address => uint256) public amountNFTsPerWallet;

    // ...
}
  • currentStep is a mechanism I use to manage the lifecycle of my contract. Basically, it will only be possible to mint when the currentStep is Sale (index 1), the two other states being useful before the mint and after the mint (when it's sold out). Alternatively, if I wanted the thing to be a bit more sophisticated, I could also have used Pausable from Open Zeppelin.
  • baseURI is the base URI where we can find the JSON metadata for all the tokens. We'll get back to it later when we talk about the tokenURI() method.
  • MAX_SUPPLY is a constant to define how many tokens there will be in my collection. I made it an unalterable constant because allowing the supply to change during the minting process is usually not a great look.
  • salePrice is the price I set to acquire a token (a very friendly price as you can see).
  • individualLimit is a limit per wallet (10 in this case) to avoid having only a few people (aka "whales") owning the entire collection. With this limit, we'll technically have a minimum of 100 owners. I say "technically" because one single individual could still buy everything on the secondary market (OpenSea) but that's not a big issue for now.
  • totalMinted is the total amount of tokens that have been minted thus far (it obviously starts at 0).
  • amountNFTsPerWallet is a mapping to know how many tokens each wallet owns (useful to make sure nobody goes above the individual limit per wallet, see above).

Let's have a look at our constructor:

// ...

contract StupidFaces is ERC721A, Ownable {
    
    // ...

    constructor() ERC721A("Stupid Faces", "STF") {}

    // ...
}

As you can see, it's the stupidest thing there is. We just set a name and a symbol for our token.

Now comes the interesting part...

// ...

contract StupidFaces is ERC721A, Ownable {
    // ...

    function mint(uint256 _quantity) external payable {
        address addr = msg.sender;
        uint256 price = salePrice;
        require(currentStep == Step.Sale, "Public sale is not active");
        require(
            amountNFTsPerWallet[addr] + _quantity <= individualLimit,
            string(
                abi.encodePacked(
                    "You can only get ",
                    Strings.toString(individualLimit),
                    " NFTs on the public sale"
                )
            )
        );
        require(
            totalMinted + _quantity <= MAX_SUPPLY,
            "Maximum supply exceeded"
        );
        require(msg.value >= price * _quantity, "Not enough funds");
        totalMinted += _quantity;
        amountNFTsPerWallet[addr] += _quantity;
        _safeMint(addr, _quantity);
    }

    function airdrop(address _addr, uint256 _quantity) external onlyOwner {
        require(
            amountNFTsPerWallet[_addr] + _quantity <= individualLimit,
            string(
                abi.encodePacked(
                    "You can only get ",
                    Strings.toString(individualLimit),
                    " NFTs on the public sale"
                )
            )
        );
        require(
            totalMinted + _quantity <= MAX_SUPPLY,
            "Maximum supply exceeded"
        );
        totalMinted += _quantity;
        amountNFTsPerWallet[_addr] += _quantity;
        _safeMint(_addr, _quantity);
    }

    // ...
}

We have two similar (but a bit different) methods for minting: mint() and airdrop(). The first one is meant to be used publicly and has the payable modifier, which means you have to pay to use it. The other one is meant for the owner of the contract (thus the onlyOwner modifier) to distribute tokens for free (it's always nice to have this possibility in case you want to be generous with some people).

Inside mint(), we have a few guards (check all the require calls) whose goals are to be sure that we respect some rules that we have put in place. These rules are arbitrary and you can choose to have different ones of course. In this particular case, we check that we're on the public sale step, that the current wallet hasn't reached its limit yet, that there are still enough tokens available to be minted, and that enough funds have been provided for the mint. Then, we increment totalMinted and amountNFTsPerWallet[addr] and only then, we mint by calling _safeMint() with a wallet address and a quantity. The quantity is something very specific to ERC721A. In OpenZeppelin's ERC-721 implementation, you need to provide a wallet address and a token identifier, which means that you have to find tricks to be able to mint multiple tokens at the same time and that usually comes with a dramatic increase in gas fees.

Next in the contract, we have the tokenURI() method:

// ...

contract StupidFaces is ERC721A, Ownable {
    // ...

    function tokenURI(uint256 _tokenId)
        public
        view
        virtual
        override
        returns (string memory)
    {
        require(_exists(_tokenId), "URI query for nonexistent token");
        return string(abi.encodePacked(baseURI, Strings.toString(_tokenId)));
    }

    // ...
}

This method is very useful to know where to look for the metadata of our tokens (marketplaces like OpenSea will need that to display our tokens correctly).

Here's an example metadata JSON file from our collection (that's been generated by Bueno in a previous article and that you can find here):

{
   "attributes":[
      {
         "trait_type":"background",
         "value":"clay"
      },
      {
         "trait_type":"body",
         "value":"regular"
      },
      {
         "trait_type":"body-background",
         "value":"green"
      },
      {
         "trait_type":"eyebrows",
         "value":"unibrow"
      },
      {
         "trait_type":"eyes",
         "value":"triangles"
      },
      {
         "trait_type":"hair",
         "value":"three"
      },
      {
         "trait_type":"mouth",
         "value":"grid"
      },
      {
         "trait_type":"nose",
         "value":"massive"
      }
   ],
   "description":"",
   "image":"ipfs://QmekXT4Qnz1fLMSgxDSafFpAddo3BSFPAWdu1CZS1JkJ2L",
   "name":"Stupid Faces #1"
}

If you want to learn more about NFT metadata, have a look at this article from OpenSea, it's very well documented.

Let's move on to our getters and setters that will be useful to make changes to some variables we decided to make editable (baseURI, salePrice, individualLimit, and currentStep in our case). Note that we've put a few guards there as well: we can only set baseURI once (to avoid metadata changing along the way) and we can only go forward for currentStep (for example, I can't go back to Prologue if I'm currently on Sale). All these methods are obviously only callable if you're the owner of the contract.

// ...

contract StupidFaces is ERC721A, Ownable {
    // ...

    function setBaseURI(string memory _baseURI) external onlyOwner {
        require(
            bytes(baseURI).length == 0,
            "You can only set the base URI once"
        );
        baseURI = _baseURI;
    }

    function getBaseURI() public view returns (string memory) {
        return baseURI;
    }

    function setSalePrice(uint256 _salePrice) external onlyOwner {
        salePrice = _salePrice;
    }

    function getSalePrice() public view returns (uint256) {
        return salePrice;
    }

    function setIndividualLimit(uint256 _individualLimit) external onlyOwner {
        individualLimit = _individualLimit;
    }

    function getIndividualLimit() public view returns (uint256) {
        return individualLimit;
    }

    function setCurrentStep(uint256 _currentStep) external onlyOwner {
        require(_currentStep > uint256(currentStep), "You can only go forward");
        currentStep = Step(_currentStep);
    }

    function getCurrentStep() public view returns (uint256) {
        return uint256(currentStep);
    }

    // ...
}

Now comes one of the most important things if you want to make some money: the withdraw() method. It is only callable if you're the owner of the contract and will transfer all the money that's in the contract to your own wallet. Believe it or not, some people forget to include this method and have a lot of money stuck forever in their contract, something that can be quite frustrating, to say the least.

// ...

contract StupidFaces is ERC721A, Ownable {
    // ...

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

    // ...
}

And the last method in the contract is a trick I had to use because ERC721A by default starts the tokenId at 0 but Bueno generated my collection starting at 1. To avoid this incompatibility, I overrode _startTokenId() to make it start at 1 instead of 0.

// ...

contract StupidFaces is ERC721A, Ownable {
    // ...

    function _startTokenId() internal view virtual override returns (uint256) {
        return 1;
    }
}

That's it. We have our contract, and a pretty solid one to be honest.

There are a lot of other things we could have done, like a system to hide metadata for unminted tokens (so it's impossible to know when a rare token will appear), a whitelist procedure, or even a delayed reveal, but that's material for future blog articles. Let's keep it simple here.

Testing our smart contract

What I like about Hardhat is that it provides a way to test your contracts out of the box with the Chai assertion library that most JavaScript developers use regularly (at least the most serious ones). It will save you from shitting your pants when you'll have to deploy your contract on the Mainnet.

The code is pretty much self-explanatory so I won't spend time explaining it line by line as I did with the contract. Just make sure that you write tests that are covering the most crucial parts of your contract. Of course, having good tests should not prevent you from deploying and testing on a test network, as we'll do later.

This is the content of the file, located at test/StupidFaces.ts:

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

const BASE_URI = "ipfs://xyz/";

let owner: any, addr1: any, addr2: any, stupidFaces: any;

describe("StupidFaces", function () {
  before(async () => {
    const [_owner, _addr1, _addr2] = await ethers.getSigners();
    owner = _owner;
    addr1 = _addr1;
    addr2 = _addr2;

    const StupidFaces = await ethers.getContractFactory("StupidFaces");
    stupidFaces = await StupidFaces.deploy();
    await stupidFaces.deployed();
  });

  it("Should allow setting and getting the current step of the pass contract", async () => {
    stupidFaces.setCurrentStep(1);

    const currentStep = await stupidFaces.getCurrentStep();

    expect(currentStep).to.equal(1);
  });

  it("Should not allow setting the current step to a previous step", async () => {
    await expect(stupidFaces.setCurrentStep(0)).eventually.to.rejectedWith(
      "You can only go forward"
    );
  });

  it("Should not allow minting of ERC721 token if not enough funds sent", async () => {
    const mint = async () => {
      await stupidFaces.connect(addr1).mint(5, {
        value: ethers.utils.parseEther("0"),
      });
    };

    await expect(mint()).eventually.to.rejectedWith("Not enough funds");
  });

  it("Should not allow minting of ERC721 token if individual limit reached", async () => {
    const limit = await stupidFaces.getIndividualLimit();

    const mint = async () => {
      await stupidFaces.connect(addr1).mint(20, {
        value: ethers.utils.parseEther("0.02"),
      });
    };

    await expect(mint()).eventually.to.rejectedWith(
      `You can only get ${limit.toNumber()} NFTs on the public sale`
    );
  });

  it("Should allow minting of ERC721 token", async () => {
    await stupidFaces.connect(addr1).mint(5, {
      value: ethers.utils.parseEther("0.005"),
    });

    const balance = await stupidFaces.balanceOf(addr1.address);

    expect(balance.toNumber()).to.equal(5);
  });

  it("Should allow setting the base URI and getting a token URI for an existent token", async () => {
    stupidFaces.setBaseURI(BASE_URI);

    const tokenURI = await stupidFaces.tokenURI(1);

    expect(tokenURI).to.equal(`${BASE_URI}1`);
  });

  it("Should not allow setting the base URI if it is already set", async () => {
    await expect(stupidFaces.setBaseURI(BASE_URI)).eventually.to.rejectedWith(
      "You can only set the base URI once"
    );
  });

  it("Should not allow getting a token URI for a nonexistent token", async () => {
    await expect(stupidFaces.tokenURI(666)).eventually.to.rejectedWith(
      "URI query for nonexistent token"
    );
  });

  it("Should allow airdropping of ERC721 token", async () => {
    await stupidFaces.airdrop(addr2.address, 3);

    const balance = await stupidFaces.balanceOf(addr2.address);

    expect(balance.toNumber()).to.equal(3);
  });
});

To run your tests, simply type the following command in your terminal:

$ npx hardhat test

If everything is fine, this is what you should see:

🎉

Deploying our smart contract

To deploy our contract, we need to write a small deploy script, located at scripts/deploy.ts:

import { ethers } from "hardhat";

async function main() {
  const [deployer] = await ethers.getSigners();

  console.log("Deploying contracts with the account:", deployer.address);

  console.log("Account balance:", (await deployer.getBalance()).toString());

  const StupidFaces = await ethers.getContractFactory("StupidFaces");
  const stupidFaces = await StupidFaces.deploy();

  console.log("StupidFaces address:", stupidFaces.address);
}

main()
  .then(() => process.exit(0))
  .catch((error) => {
    console.error(error);
    process.exit(1);
  });

The code itself is also pretty much self-explanatory. The challenge will come from the fact that we want to deploy on a remote test network. We chose the Rinkeby test network because this is the test network OpenSea is using, so we'll be able to test that our contract works by checking that everything is fine on OpenSea. Note that Rinkeby will be deprecated soon in favor of other test networks like Goerli, Ropsten, or Sepolia, so let's hope OpenSea jumps on the bandwagon soon.

The first thing to do is to add Rinkeby to MetaMask and send yourself some money.

When it's done, we'll have to change our Hardhat config file (located at hardhat.config.ts) a little bit.

This is what we should have:

import { HardhatUserConfig } from "hardhat/config";
import "@nomicfoundation/hardhat-toolbox";

const config: HardhatUserConfig = {
  solidity: "0.8.9",
};

export default config;

We'll need to add some private keys in there, so let's use environment variables to be secure. Let's start by installing dotenv:

$ npm install dotenv

And create a .env file at the root of the project:

ALCHEMY_API_KEY=abc
RINKEBY_PRIVATE_KEY=xyz

To get an Alchemy API key, you'll have to create an account and an app on Alchemy. Everything is explained very clearly in this tutorial. If you don't know what it is, Alchemy is a set of tools to make your web3 developer experience better. Put simply, Alchemy wants to be for web3 what AWS was for web2. Note that Alchemy is not mandatory here, you could use an alternative platform, the other famous one being Infura.

To get your Rinkeby private key, you'll have to export it from MetaMask. Just go to "Account details" then click on "Export Private Key". Never disclose this private key to anyone!

Your .env file should be good to go now, so let's change our Hardhat config file:

import { HardhatUserConfig } from "hardhat/config";
import "@nomicfoundation/hardhat-toolbox";

require("dotenv").config();

const ALCHEMY_API_KEY = process.env.ALCHEMY_API_KEY || "";
const RINKEBY_PRIVATE_KEY = process.env.RINKEBY_PRIVATE_KEY || "";

const config: HardhatUserConfig = {
  solidity: "0.8.9",
  networks: {
    rinkeby: {
      url: `https://eth-rinkeby.alchemyapi.io/v2/${ALCHEMY_API_KEY}`,
      accounts: [RINKEBY_PRIVATE_KEY],
    },
  },
};

export default config;

As you can see, we added the Rinkeby network in there and that's why we needed ALCHEMY_API_KEY and RINKEBY_PRIVATE_KEY.

Let's deploy!

Again, Hardhat makes it very simple for us:

$ npx hardhat run scripts/deploy.ts --network rinkeby

When this is done, it should give your the address of your newly deployed contract. You can then find it by going to Rinkeby's Etherscan. My contract is here: https://rinkeby.etherscan.io/address/0x2A77368bDB646386aD4c0Eb9ab19fA2512d18FBB

If we want to interact with the contract directly from Etherscan, we can go to the "Contract" section of the page, and click on "Verify and Publish".

This will lead us to a form where we need to provide some information:

Leave the contract address as is, choose "Solidity (Single file)" for the compiler type, "v0.8.9" for the compiler version, and "MIT" for the license type.

On the next page, you'll be invited to paste the source code of your contract. As we chose the single file option in the previous step, we'll have to flatten our contract to have its source code and the source code of all its dependencies (OpenZeppelin and ERC721A) in a single file.

We can do that with Hardhat as well:

$ npx hardhat flatten contracts/StupidFaces.sol > contracts/StupidFaces.flattened.sol

This should create a new StupidFaces.flattened.sol file in the contracts folder. Before adding it to Etherscan, make sure you only keep one occurrence of // SPDX-License-Identifier: MIT in the flattened file, otherwise Etherscan will complain (but this is really Hardhat's fault, for not being smart enough).

After all this quibbling, if everything goes fine, you should see that:

Now you can interact with your contract from the "Contract" section:

We'll need to do a few things before being ready to mint. Make sure your MetaMask is on the Rinkeby network, then do the following:

  1. Call setBaseURI() with what we got from Bueno. In my case Bueno gives me https://gateway.pinata.cloud/ipfs/QmTzFoG1vzALXJhZ3CDpygkNHxH8cbH6Jdmvpr8rH8fVgp/ but I don't want to rely on Pinata's gateway so I'll change my URI to an IPFS URI like this: ipfs://QmTzFoG1vzALXJhZ3CDpygkNHxH8cbH6Jdmvpr8rH8fVgp/. Much better!
  2. Call setStep() with 1 so the public sale is open.

These two operations are free but you'll have to pay some gas fees (but it doesn't matter because you're on Rinkeby and it's not "real" money).

Now we can mint, still directly from Etherscan.

The first param is the money you send and the second is the quantity. If you decide to mint 5 tokens, you need to send 0.005 ETH (because it's 0.001 ETH per token).

This will again open MetaMask for confirmation, then, if everything goes well, you should see some NFTs in your wallet! You can go on your profile on the OpenSea testnet to make sure that's the case.

🎉

I hope you enjoyed this tutorial (which turned out to be longer than I initially expected). As usual, you can find the entire code on our GitHub.

Oh, and if you want to support us and mint this collection for real on the Mainnet, you can do it here: https://stupidfaces.0x3.studio

Thank you!