juliancanderson

Posted on Feb 08, 2022Read on Mirror.xyz

Creating NFT Smart Contract with Foundry and Solmate

Inspired by Georgios’ tweet I’m building a simple and short tutorial on how to create an NFT contract using Foundry and Solmate.

https://twitter.com/gakonst/status/1488251153444147200

Setting up

Before using Foundry, you need to install rust. To install rust you can follow these guides:

https://doc.rust-lang.org/book/ch01-01-installation.html

https://www.rust-lang.org/tools/install

After installing Rust, all you have to do is installing Foundry. The steps are really simple and you can read it here.

https://github.com/gakonst/foundry#installation

Now all you have to do is to initialize a contract. I am a big fan of Andreas Bigger’s Foundry Starter so I’ll be using that here in this tutorial. The steps to make this template up and running is just a simple make command. Using this template Solmate is already installed so we don’t need to install it. If you want to install it, it’s really easy. You can use forge install Rari-Capital/solmate and it will be installed on your project under the lib folder. Also we’ll be using some Open Zeppelin Contract so we need to install it using forge install openzeppelin/openzeppelin-contracts .

The Contract

On Foundry, you can put the contract on the src folder. The contract that we’re going to create is called NFTToken .

// SPDX-License-Identifier: AGPL-3.0-only
pragma solidity 0.8.11;

import {ERC721} from "@solmate/tokens/ERC721.sol";
import {Strings} from "@openzeppelin/utils/Strings.sol";
import {Ownable} from "@openzeppelin/access/Ownable.sol";
import {SafeTransferLib} from "@solmate/utils/SafeTransferLib.sol";

error TokenDoesNotExist();
error MaxSupplyReached();
error WrongEtherAmount();
error MaxAmountPerTrxReached();
error NoEthBalance();

/// @title ERC721 NFT Drop
/// @title NFTToken
/// @author Julian <[email protected]>
contract NFTToken is ERC721, Ownable {
    using Strings for uint256;

    uint256 public totalSupply = 0;
    string public baseURI;

    uint256 public immutable maxSupply = 10000;
    uint256 public immutable price = 0.15 ether;
    uint256 public immutable maxAmountPerTrx = 5;

    address public vaultAddress = 0x06f75da47a438f65b2C4cc7E0ee729d5C67CA174;

    /*///////////////////////////////////////////////////////////////
                               CONSTRUCTOR
    //////////////////////////////////////////////////////////////*/

    /// @notice Creates an NFT Drop
    /// @param _name The name of the token.
    /// @param _symbol The Symbol of the token.
    /// @param _baseURI The baseURI for the token that will be used for metadata.
    constructor(
        string memory _name,
        string memory _symbol,
        string memory _baseURI
    ) ERC721(_name, _symbol) {
        baseURI = _baseURI;
    }

    /*///////////////////////////////////////////////////////////////
                               MINT FUNCTION
    //////////////////////////////////////////////////////////////*/

    /// @notice Mint NFT function.
    /// @param amount Amount of token that the sender wants to mint.
    function mintNft(uint256 amount) external payable {
        if (amount > maxAmountPerTrx) revert MaxAmountPerTrxReached();
        if (totalSupply + amount > maxSupply) revert MaxSupplyReached();
        if (msg.value < price * amount) revert WrongEtherAmount();

        unchecked {
            for (uint256 index = 0; index < amount; index++) {
                uint256 tokenId = totalSupply + 1;
                _mint(msg.sender, tokenId);
                totalSupply++;
            }
        }
    }

    /*///////////////////////////////////////////////////////////////
                            ETH WITHDRAWAL
    //////////////////////////////////////////////////////////////*/

    /// @notice Withdraw all ETH from the contract to the vault addres.
    function withdraw() external onlyOwner {
        if (address(this).balance == 0) revert NoEthBalance();
        SafeTransferLib.safeTransferETH(vaultAddress, address(this).balance);
    }

    function tokenURI(uint256 tokenId)
        public
        view
        override
        returns (string memory)
    {
        if (ownerOf[tokenId] == address(0)) {
            revert TokenDoesNotExist();
        }

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

Let’s dive deeper into the contract.

// SPDX-License-Identifier: AGPL-3.0-only
pragma solidity 0.8.11;

import {ERC721} from "@solmate/tokens/ERC721.sol";
import {Strings} from "@openzeppelin/utils/Strings.sol";
import {Ownable} from "@openzeppelin/access/Ownable.sol";
import {SafeTransferLib} from "@solmate/utils/SafeTransferLib.sol";

At the very top we define the pragma solidity version. In this example we’ll use version 0.8.11. The next line are basically all the imports on our contract. We will use the ERC721 contract from Solmate, Strings util from Open Zeppelin, Ownable from Open Zeppellin, and SafeTransferLib from Solmate.

On the next line we define the contract name and extending the ERC721 contract and also Ownable. Also we can define that we will be using the Strings lib for uint256

contract NFTToken is ERC721, Ownable {
    using Strings for uint256;
    ...
}

The next lines are the Contract Metadata. Solmate’s ERC721 does not include totalSupply so I’m going to add it myself to the contract.

    uint256 public totalSupply = 0;
    string public baseURI;

    uint256 public immutable maxSupply = 10000;
    uint256 public immutable price = 0.15 ether;
    uint256 public immutable maxAmountPerTrx = 5;

    address public vaultAddress = 0x06f75da47a438f65b2C4cc7E0ee729d5C67CA174;
  • baseURI will be the URI that we use to store our NFT token metadata.
  • maxSupply is the maximum supply of our NFT token.
  • price will be the price to mint our NFT.
  • maxAmountPerTrx will be the maximum amount that a person can mint the NFT.
  • vaultAddress will be the address for withdrawal.

Moving on to the constructor.

constructor(
        string memory _name,
        string memory _symbol,
        string memory _baseURI
    ) ERC721(_name, _symbol) {
        baseURI = _baseURI;
    }

The constructor will take 3 arguments, _name, _symbol, and _baseURI. We will pass both _name and _symbol to the ERC721 constructor and we will change our baseURI to _baseURI here.

Moving on to the next function which is the core of our NFT contract, the mint function.

function mintNft(uint256 amount) external payable {
        if (amount > maxAmountPerTrx) revert MaxAmountPerTrxReached();
        if (totalSupply + amount > maxSupply) revert MaxSupplyReached();
        if (msg.value < price * amount) revert WrongEtherAmount();

        unchecked {
            for (uint256 index = 0; index < amount; index++) {
                uint256 tokenId = totalSupply + 1;
                _mint(msg.sender, tokenId);
                totalSupply++;
            }
        }
    }
  • On the first conditional, we check whether the amount of NFT that’s going to be minted on this transaction is more than the maximum amount that we allowed.
  • The second one is the function to check whether the NFT has exceed the maximum supply that we set.
  • The last conditional is to check whether the ether value sent is exactly what we want. (amount times the price)
  • The last part of this function is to mint according to the amount that we put from the parameter, and in the end after calling _mint (function from ERC721) we will increase the totalSupply by 1.

Next function is a function for withdrawal.

function withdraw() external onlyOwner {
        if (address(this).balance == 0) revert NoEthBalance();
        SafeTransferLib.safeTransferETH(vaultAddress, address(this).balance);
    }

So on this function we only check if the balance is zero on this contract then we don’t need to run this function. If the balance is more than 0, then we can transfer everything to the vault. If you see on this function there’s onlyOwner. It means that only the owner of this contract can run it, in this case it’s usually the deployer.

The last part of the contract is the tokenURI. This function resolves the token metadata according to the ID that we put on the parameter.

function tokenURI(uint256 tokenId)
        public
        view
        override
        returns (string memory)
    {
        if (ownerOf[tokenId] == address(0)) {
            revert TokenDoesNotExist();
        }

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

Testing

This part we will cover the test for our contract. What’s amazing about using Foundry is that we can use Solidity as our testing language. By doing this we don’t need to switch context between multiple languages while we’re writing our code.

// SPDX-License-Identifier: AGPL-3.0-only
pragma solidity 0.8.11;

import {DSTestPlus} from "./utils/DSTestPlus.sol";

import {NFTToken} from "../NFTToken.sol";

contract NFTTokenTest is DSTestPlus {
    NFTToken nftToken;

    function setUp() public {
        nftToken = new NFTToken("My NFT", "MNFT", "https://");
    }

    function testMint() public {
        nftToken.mintNft{value: nftToken.price() * 5}(5);
        assertEq(nftToken.balanceOf(address(this)), 5);
        assertEq(nftToken.totalSupply(), 5);
    }

    function testSingleMint() public {
        nftToken.mintNft{value: nftToken.price() * 1}(1);
        assertEq(nftToken.totalSupply(), 1);
        assertEq(nftToken.balanceOf(address(this)), 1);
    }

    function testWithdraw() public {
        nftToken.mintNft{value: nftToken.price() * 1}(1);
        nftToken.withdraw();
        assertEq(address(nftToken.vaultAddress()).balance, 0.15 ether);
        assertEq(address(nftToken).balance, 0);
    }

    function testMintMoreThanLimit() public {
        vm.expectRevert(abi.encodeWithSignature("MaxAmountPerTrxReached()"));

        nftToken.mintNft{value: 1.2 ether}(8);
    }

    function testMintWithoutEtherValue() public {
        vm.expectRevert(abi.encodeWithSignature("WrongEtherAmount()"));

        nftToken.mintNft(1);
    }

    function testOutOfToken() public {
        vm.store(
            address(nftToken),
            bytes32(uint256(7)),
            bytes32(uint256(10000))
        );

        vm.expectRevert(abi.encodeWithSignature("MaxSupplyReached()"));

        nftToken.mintNft{value: 0.15 ether}(1);
    }

    function testOutOfTokenWhenSupplyNotMet() public {
        vm.store(
            address(nftToken),
            bytes32(uint256(7)),
            bytes32(uint256(9998))
        );

        vm.expectRevert(abi.encodeWithSignature("MaxSupplyReached()"));

        nftToken.mintNft{value: 0.45 ether}(3);
    }
}

So the first step is to set up our test contract.

import {DSTestPlus} from "./utils/DSTestPlus.sol";

import {NFTToken} from "../NFTToken.sol";

contract NFTTokenTest is DSTestPlus {
    NFTToken nftToken;

    function setUp() public {
        nftToken = new NFTToken("My NFT", "MNFT", "https://");
    }
    ...
}
  • We import DSTestPlus (included on the template) and also our NFTToken contract.
  • In the next line we will create setUp function to initialize our contract alongside its constructors.

Next part we will try to test the mint function on the contract.

    function testMint() public {
        nftToken.mintNft{value: nftToken.price() * 5}(5);
        assertEq(nftToken.balanceOf(address(this)), 5);
        assertEq(nftToken.totalSupply(), 5);
    }

    function testSingleMint() public {
        nftToken.mintNft{value: nftToken.price() * 1}(1);
        assertEq(nftToken.totalSupply(), 1);
        assertEq(nftToken.balanceOf(address(this)), 1);
    }
  • The first test we will try to mint multiple and the second one we will try to mint a single NFT.
  • Both function calls the mintNft function with the value of ether that we’re going to send.
  • Then the thing that we will assert is the totalSupply after minting should be equal to the amount we mint.
  • The second one is the balanceOf the address that mints the NFT should be equal to the amount of NFT that the address minted. balanceOf is an ERC721 method that will check the amount of NFTs owned by a certain address.

Next thing that we will test is the withdraw function so we know it works correctly.

function testWithdraw() public {
        nftToken.mintNft{value: nftToken.price() * 1}(1);
        nftToken.withdraw();
        assertEq(address(nftToken.vaultAddress()).balance, 0.15 ether);
        assertEq(address(nftToken).balance, 0);
    }
  • On the withdraw we will try to mint 1 NFT so the contract will have ether balance.
  • Then we call the withdraw function to withdraw all the ETH.
  • We will assert the vaultAddress and the NFTToken contract balance. The amount of ETH should be moved from the token contract to the vault address.

The last thing that we will test is the failing test cases. To help us with the test we will use forge-std.

https://github.com/brockelmore/forge-std

function testMintMoreThanLimit() public {
        vm.expectRevert(abi.encodeWithSignature("MaxAmountPerTrxReached()"));

        nftToken.mintNft{value: 1.2 ether}(8);
    }

    function testMintWithoutEtherValue() public {
        vm.expectRevert(abi.encodeWithSignature("WrongEtherAmount()"));

        nftToken.mintNft(1);
    }

    function testOutOfToken() public {
        vm.store(
            address(nftToken),
            bytes32(uint256(7)),
            bytes32(uint256(10000))
        );

        vm.expectRevert(abi.encodeWithSignature("MaxSupplyReached()"));

        nftToken.mintNft{value: 0.15 ether}(1);
    }

    function testOutOfTokenWhenSupplyNotMet() public {
        vm.store(
            address(nftToken),
            bytes32(uint256(7)),
            bytes32(uint256(9998))
        );

        vm.expectRevert(abi.encodeWithSignature("MaxSupplyReached()"));

        nftToken.mintNft{value: 0.45 ether}(3);
    }
}
  • The first test will be testing the max limit of token minted per transaction. We put 5 as the maximum amount. In this test we put 8 on the amount parameter when we call the mintNft function. It should be reverted since 8 exceeds the max limit.
  • The second test will test to mint without sending any ETH. It should be reverted also.
  • The third test will test to mint when the totalSupply is already 10,000 (max supply we put on the NFTToken contract). It should be reverted since we will be out of token.
  • The last test will test to mint when the totalSupply has not reached the maximum amount and then we try to mint more than the remaining supply. It should also revert since we will be out of token.

This is the end of the short tutorial on how to build NFT Contract using Foundry and Solmate. It’s not perfect and I’m open to discuss the code that I’ve written. I hope this tutorial helps you on your Solidity learning journey.

I want to thank Georgios (@gakonst) for making Foundry and all the contributors on the Foundry repo, Rari-Capital team for making Solmate, and to Andreas (@andreasbigger) for making the Foundry Starter template and helping me with my Solidity learning journey.

NFT