Inspired by Georgios’ tweet I’m building a simple and short tutorial on how to create an NFT contract using Foundry and Solmate.
Before using Foundry, you need to install rust. To install rust you can follow these guides:
After installing Rust, all you have to do is installing Foundry. The steps are really simple and you can read it here.
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
.
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 <juliancanderson@gmail.com>
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++;
}
}
}
_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"))
: "";
}
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://");
}
...
}
NFTToken
contract.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);
}
mintNft
function with the value
of ether that we’re going to send.totalSupply
after minting should be equal to the amount we mint.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);
}
withdraw
function to withdraw all the ETH.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
.
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);
}
}
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.totalSupply
is already 10,000
(max supply we put on the NFTToken contract). It should be reverted since we will be out of token.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.The last thing that we should do is to run forge test
or forge t
. All the test should pass and congrats on your ERC721 NFT Contract!
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.
You can get the full code on my Github.