This tutorial walk you through creating an OpenSea compatible NFT with Foundry and Solmate. A full implementation of this tutorial can be found here.
Start by setting up a Foundry project following the steps outlined in the Getting started section. We will also install Solmate for their ERC721 implementation, as well as some OpenZeppelin utility libraries. Install the dependencies by running the following commands from the root of your project:
forge install Rari-Capital/solmate
These dependencies will be added as git submodules to your project.
If you have followed the instructions correctly your project should be structured like this:
We are then going to rename the boilerplate contract in src/Contract.sol
to src/NFT.sol
and replace the code:
// SPDX-License-Identifier: UNLICENSED
pragma solidity 0.8.10;
import "solmate/tokens/ERC721.sol";
contract NFT is ERC721 {
uint256 public currentTokenId;
constructor(
string memory _name,
string memory _symbol
) ERC721(_name, _symbol) {
}
function mintTo(address recipient) public payable returns (uint256) {
uint256 newItemId = ++currentTokenId;
_safeMint(recipient, newItemId);
return newItemId;
}
}
Let's take a look at this very basic implementation of an NFT. We start by importing to contracts from our git submodules. We import solmate's gas optimised implementation of the ERC721 standard which our NFT contract will inherit from. Our constructor takes the _name
and _symbol
arguments for our NFT and passes them on to the constructor of the parent ERC721 implementation. Lastly we implement the mintTo
function which allows anyone to mint an NFT. This function increments the currentTokenId
and makes use of the _safeMint
function of our parent contract.
To compile the NFT contract run forge build
. By default the compiler output will be in the out
directory. To deploy our compiled contract with Forge we have to set environment variables for the RPC endpoint and the private key we want to use to deploy.
Set your environment variables by running:
export RPC_URL=<Your RPC endpoint>
export PRIVATE_KEY=<Your wallets private key>
Once set, you can deploy your NFT with Forge by running the below command while adding the relevant constructor arguments to the NFT contract:
forge create NFT --rpc-url=$RPC_URL --constructor-args <name> <symbol>
If successfully deployed, you will see the deploying wallet's address, the contract's address as well as the transaction hash printed to your terminal.
Calling functions on your NFT contract is made simple with Cast, Foundry's command-line tool for interacting with smart contracts, sending transactions, and getting chain data. Let's have a look at how we can use it to mint NFTs from our NFT contract.
Given that you already set your RPC and private key env variables during deployment, mint an NFT from your contract by running:
cast send --rpc-url=$RPC_URL <contractAddress> "mintTo(address)" <arg>
Well done! You just minted your first NFT from your contract. You can sanity check the owner of the NFT with currentTokenId
equal to 0 by running the below cast call
command. The address you provided above should be returned as the owner.
cast call --rpc-url=$RPC_URL --private-key=$PRIVATE_KEY <contractAddress> "ownerOf(uint256)" 0
Let's extend our NFT by adding metadata to represent the content of our NFTs, as well as set a minting price, a maximum supply and the possibility to withdraw the collected proceeds from minting. To follow along you can replace your current NFT contract with the code snippet below:
// SPDX-License-Identifier: UNLICENSED
pragma solidity 0.8.10;
import "solmate/tokens/ERC721.sol";
import "openzeppelin-contracts/contracts/utils/Strings.sol";
import "openzeppelin-contracts/contracts/security/PullPayment.sol";
import "openzeppelin-contracts/contracts/access/Ownable.sol";
contract Nft is ERC721, PullPayment, Ownable {
using Strings for uint256;
string public baseURI;
uint256 public currentTokenId;
uint256 public constant TOTAL_SUPPLY = 10_000;
uint256 public constant MINT_PRICE = 0.08 ether;
constructor(
string memory _name,
string memory _symbol,
string memory _baseURI
) ERC721(_name, _symbol) {
baseURI = _baseURI;
}
function mintTo(address recipient) public payable returns (uint256) {
require(
msg.value == MINT_PRICE,
"Transaction value did not equal the mint price"
);
uint256 newTokenId = ++currentTokenId;
require(newTokenId <= TOTAL_SUPPLY, "Max supply reached");
_safeMint(recipient, newTokenId);
return newTokenId;
}
function tokenURI(uint256 tokenId)
public
view
virtual
override
returns (string memory)
{
require(
ownerOf[tokenId] != address(0),
"ERC721Metadata: URI query for nonexistent token"
);
return
bytes(baseURI).length > 0
? string(abi.encodePacked(baseURI, tokenId.toString()))
: "";
}
/// @dev Overridden in order to make it an onlyOwner function
function withdrawPayments(address payable payee) public override onlyOwner {
super.withdrawPayments(payee);
}
}
Among other things, we have added metadata that can be queried from any front-end application like OpenSea, by calling the tokenURI
method on our NFT contract.
Note: If you want to provide a real URL to the constructor at deployment, and host the metadata of this NFT contract please follow the steps outlined here.
Let's test some of this added functionality to make sure it works as intended. Foundry offers an extremely fast EVM native testing framework through Forge.
Within your test folder rename the current Contract.t.sol
test file to NFT.t.sol
. This file will contain all tests regarding the NFT's mintTo
method. Next, replace the existing boilerplate code with the below:
// SPDX-License-Identifier: UNLICENSED
pragma solidity 0.8.10;
import "ds-test/test.sol";
import "forge-std/stdlib.sol";
import "../NFT.sol";
import "./interfaces/HEVM.sol";
contract Nft is DSTest {
using stdStorage for StdStorage;
Hevm private vm = Hevm(HEVM_ADDRESS);
NFT private nft;
StdStorage private stdstore;
function setUp() public {
// Deploy NFT contract
nft = new NFT("NFT_tutorial", "TUT", "baseUri");
}
function testFailNoMintPricePaid() public {
nft.mintTo(address(1));
}
function testMintPricePaid() public {
nft.mintTo{value: 0.08 ether}(address(1));
}
function testFailMaxSupplyReached() public {
uint256 slot = stdstore.target(address(nft)).sig("currentTokenId()").find();
bytes32 loc = bytes32(slot);
bytes32 mockedCurrentTokenId = bytes32(abi.encode(10000));
vm.store(address(nft), loc, mockedCurrentTokenId);
nft.mintTo{value: 0.08 ether}(address(1));
}
function testFailMintToZeroAddress() public {
nft.mintTo{value: 0.08 ether}(address(0));
}
function testNewMintOwnerRegistered() public {
nft.mintTo{value: 0.08 ether}(address(1));
uint256 slotOfNewOwner = stdstore
.target(address(nft))
.sig(nft.ownerOf.selector)
.with_key(1)
.find();
uint160 ownerOfTokenIdOne = uint160(uint256((vm.load(address(nft),bytes32(abi.encode(slotOfNewOwner))))));
assertEq(address(ownerOfTokenIdOne), address(1));
}
function testBalanceIncremented() public {
nft.mintTo{value: 0.08 ether}(address(1));
uint256 slotBalance = stdstore
.target(address(nft))
.sig(nft.balanceOf.selector)
.with_key(address(1))
.find();
uint256 balanceFirstMint = uint256(vm.load(address(nft), bytes32(slotBalance)));
assertEq(balanceFirstMint, 1);
nft.mintTo{value: 0.08 ether}(address(1));
uint256 balanceSecondMint = uint256(vm.load(address(nft), bytes32(slotBalance)));
assertEq(balanceSecondMint, 2);
}
function testSafeContractReceiver() public {
Receiver receiver = new Receiver();
nft.mintTo{value: 0.08 ether}(address(receiver));
uint256 slotBalance = stdstore
.target(address(nft))
.sig(nft.balanceOf.selector)
.with_key(address(receiver))
.find();
uint256 balance = uint256(vm.load(address(nft), bytes32(slotBalance)));
assertEq(balance, 1);
}
function testFailUnSafeContractReceiver() public {
vm.etch(address(1), bytes("mock code"));
nft.mintTo{value: 0.08 ether}(address(1));
}
}
contract Receiver is ERC721TokenReceiver {
function onERC721Received(
address operator,
address from,
uint256 id,
bytes calldata data
) external returns (bytes4){
return this.onERC721Received.selector;
}
}
The test suite is set up as a contract with a setUp
method which runs before every individual test.
As you can see, Forge offers a number of cheatcodes to manipulate state to accomodate your testing scenario.
For example, our testFailMaxSupplyReached
test checks that an attempt to mint fails when the max supply of NFT is reached. Thus, the currentTokenId
of the NFT contract needs to be set to the max supply by using the store cheatcode which allows you to write data to your contracts storage slots. The storage slots you wish to write to can easily be found using the
forge-std
helper library. You can run the test with the following command:
forge test
If you want to put your Forge skills to practice, write tests for the remaining methods of our NFT contract. Feel free to PR them to [nft-tutorial]((https://github.com/FredCoen/nft-tutorial), where you will find the full implemenation of this tutorial.
Foundry provides comprehensive gas reports about your contracts. For every function called within your tests, it returns the minimum, average, median and max gas cost. To print the gas report simply run:
forge test --gas-report
This comes in handy when looking at various gas optimizations within your contracts.
Let's have a look at the gas savings we made by substituting OpenZeppelin with Solmate for our ERC721 implementation. You can find the NFT implementation using both libraries here. Below are the resulting gas reports when running forge test --gas-report
on that repository.
As you can see, our implementation using Solmate saves around 500 gas on a successful mint (the max gas cost of the mintTo
function calls).
That's it, I hope this will give you a good practical basis of how to get started with foundry. We think there is no better way to deeply understand solidity than writing your tests in solidity. You will also experience less context switching between javascript and solidity. Happy coding!