Develop a Soulbound NFT using Foundry and Slither
Today we will develop a Soulbound NFT, an NFT that can be only minted and not traded or transferred, it is bounded to the first owner. We'll do it using foundry with hardhat integrated. The Github workflow will test (foundry solidity and hardhat typescript) the contracts and uses Slither to statically analyze the code (opens in a new tab), trying to find the most common vulnerabilities.
You can find all the code used in my repository here (opens in a new tab)
1. Install Foundry and create the project
1.1 Install Foundry (opens in a new tab).
curl -L https://foundry.paradigm.xyz | sh1.2 Create the a new foundry project
forge init soulbound-nft1.3 Install the openzeppelin library
forge install openzeppelin/openzeppelin-contracts2. Write the smart contract
Rename the smart contract in the src folder to NFTToken.sol or the name you want.
2.1 Use a pragma solidity directive to specify the compiler version.
pragma solidity ^0.8.13;Use a version above 0.6.0 to avoid errors and previous vulns, like overflow and underflow. I prefer the latest stable version.
2.2 Import the openzeppelin libraries.
import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/utils/Counters.sol";
import "@openzeppelin/contracts/utils/Strings.sol";
import "@openzeppelin/contracts/access/Ownable.sol";2.3 Inherit from the ERC721 and Ownable contract.
contract NFTToken is ERC721, Ownable {
...
}Ownable is a library that allows the contract to be owned by a single address. Later we will implement the onlyOwner modifier to allow only the owner to call the functions.
2.4. Setup a counter for the token incremental id.
using Counters for Counters.Counter;
Counters.Counter private _tokenIdCounter;Here we are making a new counter and naming it _tokenIdCounter, using the safemath library.
2.5 Setup the token name and symbol.
constructor() ERC721("Soulbound", "SBNFT") {}If you feel confident you can even make a factory to create the token.
2.6. Write the safeMint function.
function safeMint(address to) public onlyOwner {
uint256 tokenId = _tokenIdCounter.current();
_tokenIdCounter.increment();
_safeMint(to, tokenId);
}This function is similar to the mint function in the ERC721 contract, but it is protected by the onlyOwner modifier and uses the _safeMint function. An Internal function to safely mint a new token. Reverts if the given token ID already exists. If the target address is a contract, it must implement onERC721Received, which is called upon a safe transfer, and return the magic value bytes4(keccak256... otherwise, the transfer is reverted. Source Openzeppelin documentation (opens in a new tab).
2.7 Let's implement the soulbound features
modifier oneTransfer(address from) {
require(
from == 0x0000000000000000000000000000000000000000,
"Soulbound nft can't be transferred"
);
_;
}
function _beforeTokenTransfer(
address from,
address to,
uint256 tokenId
) internal override oneTransfer(from) {
super._beforeTokenTransfer(from, to, tokenId);
}
The oneTransfer modifier is used to prevent the transfer of the token to any other address, the 0x0000000000000000000000000000000000000000 address it's an impossible from address. We implement oneTransfer to the _beforeTokenTransfer function. The _beforeTokenTransfer overrides the _beforeTokenTransfer function in the ERC721 contract, and it is called before the transfer, now we have a soulbound nft.
2.8 Recap the entire contract.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.13;
import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/utils/Counters.sol";
import "@openzeppelin/contracts/utils/Strings.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
contract NFTToken is ERC721, Ownable {
using Counters for Counters.Counter;
Counters.Counter private _tokenIdCounter;
constructor() ERC721("Soulbound", "SBNFT") {}
modifier oneTransfer(address from) {
require(
from == 0x0000000000000000000000000000000000000000,
"Soulbound nft can't be transferred"
);
_;
}
function safeMint(address to) public onlyOwner {
uint256 tokenId = _tokenIdCounter.current();
_tokenIdCounter.increment();
_safeMint(to, tokenId);
}
function _beforeTokenTransfer(
address from,
address to,
uint256 tokenId
) internal override oneTransfer(from) {
super._beforeTokenTransfer(from, to, tokenId);
}
}
3. Write Foundry tests in solidity
Foundry tests are a way to test the contracts in solidity, they are really fast compared to truffle or hardhat. Foundry is made in Rust, so it's blazing fast. Rename the test contract file in the test folder to NFTToken.t.sol, or the name you want that respects the naming convention NAMECONTRACT.t.sol.
3.1 Setup the test environment.
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
import "forge-std/Test.sol";
import "../src/NFTToken.sol";
contract NFTTokenTest is Test {
using stdStorage for StdStorage;
NFTToken private nft;
function setUp() public {
nft = new NFTToken();
}
}I import the Test contract, and the NFTToken contract. NFTTokenTest is the name of the test contract. We are using the stdStorage, a library that makes manipulating storage easy (opens in a new tab). setUp() is a function that is called before each test, and it initializes the contract.
3.2 Test Smart contract functions.
Here I'm testing the smart contract functions. The pattern is easy to understand, we test the function correct output and revert if it's not correct with the vm.expectRevert() function. The vm.startPrank() and vm.stopPrank() functions are used to simulate a user that is not the owner. By using solidity to write tests we can test the smart contract functions the closest possible to a real user or external contract.
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
import "forge-std/Test.sol";
import "../src/NFTToken.sol";
contract NFTTokenTest is Test {
using stdStorage for StdStorage;
NFTToken private nft;
function setUp() public {
nft = new NFTToken();
}
function testDeployment() public {
assertEq(nft.name(), "Soulbound");
assertEq(nft.symbol(), "SBNFT");
}
function testOwner() public {
assertEq(nft.owner(), address(this));
}
function testMintFailByNotOwnerUser() public {
vm.expectRevert("Ownable: caller is not the owner");
vm.startPrank(address(2));
nft.safeMint(address(2));
vm.stopPrank();
}
function testMint() public {
nft.safeMint(address(1));
assertEq(nft.balanceOf(address(1)), 1);
assertEq(nft.ownerOf(0), address(1));
}
function testTransferFail() public {
nft.safeMint(address(2));
vm.expectRevert("Soulbound nft can't be transferred");
vm.startPrank(address(2));
nft.safeTransferFrom(address(2), address(3), 0);
vm.stopPrank();
}
function testOwnerTransfer() public {
assertEq(nft.owner(), address(this));
nft.transferOwnership(0x1111111111111111111111111111111111111111);
assertEq(nft.owner(), 0x1111111111111111111111111111111111111111);
}
function testOwnerTransferFail() public {
assertEq(nft.owner(), address(this));
vm.expectRevert("Ownable: caller is not the owner");
vm.startPrank(address(2));
nft.transferOwnership(0x1111111111111111111111111111111111111111);
vm.stopPrank();
}
}
3.3 Test contract using Forge
forge testAll test cases should pass.
4. Integrating Foundry with Hardhat
Hardhat by default expects libraries to be installed in node_modules, the default folder for all NodeJS dependencies. Foundry expects them to be in lib. Of course we can configure Foundry but not easily to the directory structure of node_modules. Documentation (opens in a new tab) has more information.
4.1 Install hardhat
yarn init
yarn add hardhat hardhat-preprocessor
npx hardhat
forge remappings > remappings.txtYou will need to re-run forge remappings everytime you modify libraries in Foundry. Now your remappings.txt should look like this:
ds-test/=lib/solmate/lib/ds-test/src/
forge-std/=lib/forge-std/src/
openzeppelin-contracts/contracts/=lib/openzeppelin-contracts/contracts/
solmate/=lib/solmate/src/4.2 Configure hardhat
Edit hardhat.config.ts to look like this:
import fs from 'fs'
import '@nomicfoundation/hardhat-chai-matchers'
import '@typechain/hardhat'
import 'hardhat-preprocessor'
import { HardhatUserConfig, task } from 'hardhat/config'
function getRemappings() {
return fs
.readFileSync('remappings.txt', 'utf8')
.split('\n')
.filter(Boolean)
.map((line) => line.trim().split('='))
}
const config: HardhatUserConfig = {
solidity: {
version: '0.8.13',
settings: {
optimizer: {
enabled: true,
runs: 200
}
}
},
paths: {
sources: './src', // Use ./src rather than ./contracts as Hardhat expects
cache: './cache_hardhat' // Use a different cache for Hardhat than Foundry
},
// This fully resolves paths for imports in the ./lib directory for Hardhat
preprocess: {
eachLine: (hre) => ({
transform: (line: string) => {
if (line.match(/^\s*import /i)) {
getRemappings().forEach(([find, replace]) => {
if (line.match(find)) {
line = line.replace(find, replace)
}
})
}
return line
}
})
}
}
export default config5. Write Hardhat tests in typescript
5.1 Create test contract
Create the contract in the test folder using this name convention nfttoken.test.ts.
5.2 Write test hardhat file
Here we do the same as the Foundry tests, but we use the hardhat expect and the to.be.revertedWith function. If the revertedWith is not recognized install the "@nomicfoundation/hardhat-chai-matchers package. More info here (opens in a new tab) about this.
import { SignerWithAddress } from '@nomiclabs/hardhat-ethers/signers'
import { expect } from 'chai'
import { ethers } from 'hardhat'
import { NFTToken } from '../typechain-types'
describe('NFTToken', function () {
let owner: SignerWithAddress
let addr1: SignerWithAddress
let addr2: SignerWithAddress
let nft: NFTToken
beforeEach(async function () {
const Nft = await ethers.getContractFactory('NFTToken')
nft = await Nft.deploy()
await nft.deployed()
;[owner, addr1, addr2] = await ethers.getSigners()
})
it('Should return name and symbol', async function () {
expect(await nft.name()).to.equal('Soulbound')
expect(await nft.symbol()).to.equal('SBNFT')
})
it('Should set the first account as the owner', async () => {
expect(await nft.owner()).to.equal(owner.address)
})
it('Should NOT mint a token as NOT the Owner', async () => {
await expect(nft.connect(addr1).safeMint(addr1.address)).to.be.revertedWith(
'Ownable: caller is not the owner'
)
})
it('Should mint a token as the Owner', async () => {
await nft.safeMint(addr1.address)
expect(await nft.ownerOf(0)).to.equal(addr1.address)
expect(await nft.balanceOf(addr1.address)).to.equal(1)
})
it('Should not be able to transfer soulbound token', async () => {
await nft.safeMint(owner.address)
//OVERLOADED TRANSFER function
await expect(
nft['safeTransferFrom(address,address,uint256)'](
owner.address,
addr2.address,
0
)
).to.be.revertedWith("Soulbound nft can't be transferred")
})
it('Should NOT transfer the contract NON owner to the new owner', async () => {
await expect(
nft.connect(addr1).transferOwnership(addr2.address)
).to.be.revertedWith('Ownable: caller is not the owner')
})
it('Should transfer the contract owner to the new owner', async () => {
await nft.transferOwnership(addr2.address)
expect(await nft.owner()).to.equal(addr2.address)
})
})Different addresses are simulated by using the connect function. The beforeEach function is used to set up the contract and the it function is used to test the contract.
5.3 Hardhat VS Forge VS Truffle
I prefer Forge, is faster and closer to the real environment. Hardhat right now is optional, but I would recommend using it as an alternative. Truffle is now obsolete and will be less and less used in the future.
6. Create a Github workflow with tests and slither audit
6.1 Create a Github workflow
Create a file named .github/workflows/audit.yml and put this content:
name: Audit
on:
push:
branches: [main, develop]
pull_request:
branches: [main, develop]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
with:
submodules: recursive
- name: Install Foundry
uses: foundry-rs/foundry-toolchain@v1
with:
version: nightly
- name: Run Forge build
run: |
forge --version
forge build --sizes
id: build
- name: Run Forge tests
run: |
forge test -vvv
id: forge-testThis will install Foundry and run the Forge build and tests.
6.2 Optional Hardat tests
If you want to use hardhat add a step that uses yarn to instal the packages and run the command npx hardhat test, this will run the tests with Hardhat.
- name: Setup NodeJS 14
uses: actions/setup-node@v2
with:
node-version: '14'
- name: Show NodeJS version
run: npm --version
- name: Install Dependencies
run: npm install
- name: Run hardhat Test
run: npx hardhat compile; npx hardhat testShould look like this:
6.3 Add slither audit
Slither is a Solidity static analysis framework written in Python 3. It runs a suite of vulnerability detectors, prints visual information about contract details, and provides an API to easily write custom analyses. Slither enables developers to find vulnerabilities, enhance their code comprehension, and quickly prototype custom analyses. Source (opens in a new tab)
We can even add slither manually, but I prefer to use slither-action command. It's a great tool because will push slither's alerts to the Security tab of the Github project, easing the triaging of findings and improving the continious integration flow.
- name: slither-action
uses: crytic/slither-action@v0.1.1
continue-on-error: true
id: slither
with:
sarif: results.sarif
- name: Upload SARIF file
uses: github/codeql-action/upload-sarif@v2
with:
sarif_file: ${{ steps.slither.outputs.sarif }}If everything is correct you will this result in the Security tab of the Github project:
6.4 Recap
They yaml file is used to create a Github workflow that will run the tests and slither audit. Should look like this:
name: Hardhat Build
on:
push:
branches: [main, develop]
pull_request:
branches: [main, develop]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
with:
submodules: recursive
- name: Install Foundry
uses: foundry-rs/foundry-toolchain@v1
with:
version: nightly
- name: Run Forge build
run: |
forge --version
forge build --sizes
id: build
- name: Run Forge tests
run: |
forge test -vvv
id: forge-test
- name: Setup NodeJS 14
uses: actions/setup-node@v2
with:
node-version: '14'
- name: Show NodeJS version
run: npm --version
- name: Install Dependencies
run: yarn
- name: Run hardhat Test
run: npx hardhat compile; npx hardhat test
- name: slither-action
uses: crytic/slither-action@v0.1.1
continue-on-error: true
id: slither
with:
sarif: results.sarif
- name: Upload SARIF file
uses: github/codeql-action/upload-sarif@v2
with:
sarif_file: ${{ steps.slither.outputs.sarif }}