53
Import & Test a Popular NFT Smart Contract with Hardhat & Ethers
Today we're going to learn how to use the very cool smart-contract development framework Hardhat to locally import & test a publicly deployed smart contract. To make things fun & relevant, we'll be using the Bored Ape Yacht Club NFT smart contract in our example. Using a well-known project's smart contract should make it clear how open the Ethereum ecosystem is, and how many opportunities there are to get started in Dapp and smart contract development!
By the end of this tutorial you will know the following:
- How to find smart contract code for specific projects
- How to add that code to a local development environment
- How to install & set-up a simple Hardhat development environment
- How to compile a contract and write tests for it
This tutorial won't involve any front-end development, but if you're interested in understanding how to get started with Web3 dapp development, feel free to check out my previous tutorials here on dev.to:
To begin with, we're going to start by choosing a project (Bored Ape Yacht Club), and then tracking down the smart contract code. Personally the first thing I'd do in this case is quickly check out the website of the project in question to see if they have a link to their contracts. In this case, https://boredapeyachtclub.com/ only contains social links, so we'll have to look elsewhere.
Since Bored Ape Yacht Club is an Ethereum-based NFT project, our next port of call will be Etherscan, the Ethereum blockchain explorer. Since I know that Bored Ape Yacht Club uses the symbol BAYC, I can just search for that symbol using Etherscan (why, yes, I use dark mode on everything, how could you tell?):
And there we go - we can see that this is a verified ERC-721 token contract with the name we're looking for! If we click on the search result we'll go through to the page for the BoredApeYachtClub token, with the Etherscan address: https://etherscan.io/token/0xbc4ca0eda7647a8ab7c2061c2e118a18a936f13d
That's great, we're getting closer - in the top right hand section of the token page, called "Profile Summary", we will see a "Contract" address with a link:
If we click that we'll arrive at the "Contract" page on Etherscan - this is what we're looking for! Click on the "Contract" tab:
And there we have it - the verified contract source code for the contract named BoredApeYachtClub. Here's the Etherscan link to that specific section: https://etherscan.io/address/0xbc4ca0eda7647a8ab7c2061c2e118a18a936f13d#code
Now, at this point you might be wondering if there's a way to programmatically retrieve the contract code, given that we know the contract name, symbol and address. The answer is: of course :) But let's do it the manual way for now, I'll leave it to you to devise some more efficient ways to grab the contract using code ;)
We've almost finished step 1 - we can copy the contract code and save it somewhere - for now you can just put it in a note pad or save it in a file somewhere, we're going to come back to this file later on in the tutorial. Next up, we'll set up our Hardhat environment..
Tooling for Ethereum development hasn't had very long to evolve - the initial release of Ethereum was in July, 2015 - as of the writing of this article it has been only 6 years (which is hard to believe considering how far the Ethereum ecosystem has come during that time). Thanks to the efforts of the Ethereum community, we've progressed from rudimentary development environments that were only intuitive for experienced developers, through to 2021, where we've been blessed with finely-crafted frameworks, tools & libraries that make developing for the Ethereum ecosystem a breeze.
The folks over at Nomic Labs have had their heads down building what has quickly become the gold-standard in Ethereum development environments: Hardhat. It encompasses test running, compilation, deployment, a rich plugin-system and a local network to run everything against. When combined with other great tools like Ethers, Waffle, and Chai, Hardhat puts an entire control panel in front of you to take an Ethereum project all the way from idea to IDO.
NOTE: The instructions for this section can also be found in more detail here: https://hardhat.org/getting-started/#overview
Let's start by creating a new folder in your local environment:
mkdir hardhat-tutorial
Move into that new folder, run npm init -Y
, and then install hardhat:
npm i -D hardhat
Now run npx hardhat
and select "Create an empty hardhat.config.js":
That will add a hardhat.config.js file for us, which we'll have a look at soon. We're also going to install some other tools, including the Waffle test suite and Ethers. So run:
npm i -D @nomiclabs/hardhat-waffle ethereum-waffle chai @nomiclabs/hardhat-ethers ethers
Let's go all the way and make our Hardhat project TypeScript ready.
First, install TypeScript and some types:
npm i -D ts-node typescript @types/node @types/chai @types/mocha
Then we'll rename our hardhat.config.js
file to be hardhat.config.ts
:
mv hardhat.config.js hardhat.config.ts
We now need to make a change to our hardhat.config.ts
file, since with a Hardhat TypeScript project plugins need to be loaded with import
instead of require
, and functions must be explictly imported:
Change this:
// hardhat.config.ts
require("@nomiclabs/hardhat-waffle");
task("accounts", "Prints the list of accounts", async (taskArgs, hre) => {
const accounts = await hre.ethers.getSigners();
for (const account of accounts) {
console.log(await account.address);
}
});
/**
* @type import('hardhat/config').HardhatUserConfig
*/
module.exports = {
solidity: "0.7.3",
};
Into this:
// hardhat.config.ts
import { task } from "hardhat/config"; // import function
import "@nomiclabs/hardhat-waffle"; // change require to import
task("accounts", "Prints the list of accounts", async (taskArgs, hre) => {
const accounts = await hre.ethers.getSigners();
for (const account of accounts) {
console.log(await account.address);
}
});
export default {
solidity: "0.7.3",
};
Sweet - we're setup with TypeScript. Now if you run npx hardhat
again you should see some help instructions in your console:
Great! If you've made it this far we have a Hardhat project configured with TypeScript and our required tools are installed.
Notice in the screenshot above that there is a section called "Available Tasks" - this is a list of built-in tasks that are provided by the Hardhat team, enabling us to run important tasks from the get-go. Hardhat is extremely malleable, and works with 3rd party plugins that help us to adapt our project to our specific needs. We've already installed the hardhat-waffle and hardhat-ethers plugins, and you can find an extensive list of plugins here: https://hardhat.org/plugins/
We can also create our own tasks. If you open hardhat.config.ts
you'll see the sample "accounts" task definition. The task definition function takes 3 arguments - a name, a description, and a callback function that carries out the task. If you change the description of the "accounts" task, to "Hello, world!", and then run npx hardhat
in your console, you'll see that the "accounts" task now has the description "Hello, world!".
// hardhat.config.ts
import { task } from "hardhat/config";
import "@nomiclabs/hardhat-waffle";
task("accounts", "Hello, world!", async (taskArgs, hre) => {
const accounts = await hre.ethers.getSigners();
for (const account of accounts) {
console.log(account.address);
}
});
/**
* @type import('hardhat/config').HardhatUserConfig
*/
export default {
solidity: "0.7.3",
};
Now our simple Hardhat project is all set up, let's move on to importing & compiling our Bored Ape contract...
Let's start by creating a new folder called contracts
in our root directory (Hardhat uses the "contracts" folder as the source folder by default - if you want to change that name, you'll need to configure it within the hardhat.config.ts
file):
mdkir contracts
Create a new file called bored-ape.sol
in the contracts folder, then paste the contract code that we copied from Etherscan earlier.
NOTE: The .sol extension is the Solidity file extension. To add syntax highlighting and type hints for Solidity files, there is a great VSCode extension made by Juan Blanco called "solidity" - I recommend installing it to make developing Solidity easier:
I also use a VSCode extension called "Solidity Visual Developer", and you'll find many more in the VSCode marketplace.
Now that we have a contracts
folder with the bored-ape.sol
contract inside it, we are ready to compile the contract. We can use a built-in compile
task to do this - all we need to do is run:
npx hardhat compile
When we compile a contract with Hardhat, two files will be generated for each contract and placed into a artifacts/contracts/<CONTRACT NAME>
folder. These two files (an "artifact" .json file, and a debug "dbg" .json file) will be generated for each contract - the Bored Ape contract code that we copied from Etherscan actually contains multiple "contracts".
If you view the original contracts/bored-ape.sol
file you can see that the "contract" keyword is used 15 times in total, and each instance has its own contract name - therefore, after compiling the bored-ape.sol
file we will end up with 30 files in the artifacts/contracts/bored-ape.sol/
folder.
That's ok though - since Solidity contracts are essentially object-oriented classes, we need only be concerned with the BoredApeYachtClub.json
artifact - this is the file that contains the "BoredApeYachtClub" ABI (the Application Binary Interface, a JSON representation of the contract's variables & functions), and is exactly what we need to pass into Ethers in order to create a contract instance.
We've now achieved 3 out of our 4 objectives - our last objective for this tutorial is to write a test file so that we can run tests against our imported contract.
Testing is a deep and complex subject, so we're going to keep this simple, so that you understand the general process and can dive deeper into the subject at your own pace. Our goal for this step will be to setup and write some tests for the "BoredApeYachtClub" contract.
We've already installed "hardhat-ethers", which is a Hardhat plugin that will give us access to the "Ethers" library, and enable us to interact with our smart contract.
NOTE: If you have a JavaScript / Hardhat project, all of the properties of the Hardhat Runtime Environment are automatically injected into the global scope. When using TypeScript, however, nothing is available in the global scope, so we have to import instances explicitly.
Let's create a new test in the test
folder in the root directory, and call it bored-ape.test.ts
. Now we'll write a test, and I'll explain what we're doing in the code comments:
// bored-ape.test.ts
// We are using TypeScript, so will use "import" syntax
import { ethers } from "hardhat"; // Import the Ethers library
import { expect } from "chai"; // Import the "expect" function from the Chai assertion library, we'll use this in our test
// "describe" is used to group tests & enhance readability
describe("Bored Ape", () => {
// "it" is a single test case - give it a descriptive name
it("Should initialize Bored Ape contract", async () => {
// We can refer to the contract by the contract name in
// `artifacts/contracts/bored-ape.sol/BoredApeYachtClub.json`
// initialize the contract factory: https://docs.ethers.io/v5/api/contract/contract-factory/
const BoredApeFactory = await ethers.getContractFactory("BoredApeYachtClub");
// create an instance of the contract, giving us access to all
// functions & variables
const boredApeContract = await BoredApeFactory.deploy(
"Bored Ape Yacht Club",
"BAYC",
10000,
1
);
// use the "expect" assertion, and read the MAX_APES variable
expect(await boredApeContract.MAX_APES()).to.equal(5000);
});
});
That's a fair bit of code! Essentially we are creating a Contract Factory, which contains additional information necessary to deploy a contract. Once we have the Contract Factory, we can use the .deploy()
method, passing in variables that are required by the contract constructor. Here is the original contract constructor:
//bored-ape.sol
constructor(string memory name, string memory symbol, uint256 maxNftSupply, uint256 saleStart) ERC721(name, symbol)
The constructor takes 4 arguments, each with type definitions:
- name, a string
- symbol, a string
- maxNftSupply, a number
- saleStart, a number
Ok - now comes the moment of truth - let's run our test with:
npx hardhat test
You should see something like this:
But hang on - why is it failing? Well, we can see under 1) Bored Ape AssertionError: Expected "10000" to be equal 5000
. This is nothing to worry about - I've deliberately added a test case that will fail on the first run - this is good practice, to help remove false positives. If we don't add a failing case to begin with, we can't be certain that we aren't accidentally writing a test that will always return true. A more thorough version of this method would actually begin with creating the test first and then gradually writing code to make it pass, but since it's not the focus of this tutorial we'll gloss over that. If you're interested in learning more about this style of writing tests and then implementing code to make it pass, here are a couple of good introductions:
To make our test pass, edit this line to include 10000:
expect(await boredApeContract.MAX_APES()).to.equal(10000);
Nice! We now have a passing test case :) Let's write a few more tests to flex our muscles.
Before we do that though, we're going to use a helper function called beforeEach
that will simplify the setup for each test, and allow us to reuse variables for each test. We'll move our contract deployment code into the beforeEach
function, and as you can see, we can use the boredApeContract
instance in our "initialize" test:
// bored-ape.test.ts
import { expect } from "chai";
import { ethers } from "hardhat";
import { beforeEach } from "mocha";
import { Contract } from "ethers";
import { SignerWithAddress } from "@nomiclabs/hardhat-ethers/signers";
describe("Bored Ape", () => {
let boredApeContract: Contract;
let owner: SignerWithAddress;
let address1: SignerWithAddress;
beforeEach(async () => {
const BoredApeFactory = await ethers.getContractFactory(
"BoredApeYachtClub"
);
[owner, address1] = await ethers.getSigners();
boredApeContract = await BoredApeFactory.deploy(
"Bored Ape Yacht Club",
"BAYC",
10000,
1
);
});
it("Should initialize the Bored Ape contract", async () => {
expect(await boredApeContract.MAX_APES()).to.equal(10000);
});
it("Should set the right owner", async () => {
expect(await boredApeContract.owner()).to.equal(await owner.address);
});
});
Since we're using TypeScript, we've imported types for our variables in "beforeEach", and have added an "owner" and "address1" variable that can be used in test cases that require addresses. We've made use of the owner variable by adding another test "Should set the right owner" - this checks that the owner of the contract is the same one that is returned when we deployed the contract.
In the bored-ape.sol
file, notice that there is a function called mintApe
which takes in both a number of tokens (representing Bored Ape NFTs), and also expects to receive some ETH. Let's write a test for that function, which will let us try out payments, and force us to make use of some other methods in the contract to make the test pass.
We'll start by defining the test:
// bored-ape.test.ts
it("Should mint an ape", async () => {
expect(await boredApeContract.mintApe(1)).to.emit(
boredApeContract,
"Transfer"
);
});
Since the mintApe
method doesn't return a value, we are going to listen for an event called "Transfer" - we can trace the mintApe
function's inheritance and see that ultimately it calls the _mint
function of an ERC-721 token and emits a { Transfer } event:
At the moment it doesn't matter that we listen for the "Transfer" event - this test is going to fail since mintApe
contains a number of conditions that we haven't fulfilled:
We can see that an error "Sale must be active to mint Ape", so it looks like first we have to call the contract method flipSaleState
:
// bored-ape.test.ts
await boredApeContract.flipSaleState();
Run npx hardhat test
and...we're still failing - but with a different error! A different error is actually great news, because it means we're making progress :) Looks like "Ether value sent is not correct" - which makes sense, since we didn't send any ETH along with our contract call. Notice that the mintApe
method signature contains the keyword "payable":
// bored-ape.sol
function mintApe(uint numberOfTokens) public payable
That means that this method can (and expects to) receive ETH. We can retrieve the required cost of a Bored Ape first by calling the apePrice
getter method:
// bored-ape.sol
uint256 public constant apePrice = 80000000000000000; //0.08 ETH
Finally, we need to import some more functions, use apePrice
as our value, and send it through as ETH with our call to mintApe
. We'll also chain another method called withArgs
to our emit
call, which will give us the ability to listen to the arguments emitted by the "Transfer" event:
// bored-ape.test.ts
import chai from "chai";
import { solidity } from "ethereum-waffle";
chai.use(solidity)
it("Should mint an ape", async () => {
await boredApeContract.flipSaleState();
const apePrice = await boredApeContract.apePrice();
const tokenId = await boredApeContract.totalSupply();
expect(
await boredApeContract.mintApe(1, {
value: apePrice,
})
)
.to.emit(boredApeContract, "Transfer")
.withArgs(ethers.constants.AddressZero, owner.address, tokenId);
});
We're using an "overrides" object (https://docs.ethers.io/ethers.js/html/api-contract.html#overrides) to add additional data to our method call - in this case, a value property that will be received by the contract's mintApe
method as msg.value
, ensuring that we now satisfy the condition of the "Ether value sent is not correct" requirement:
// bored-ape.sol
require(apePrice.mul(numberOfTokens) <= msg.value, "Ether value sent is not correct");
We've imported chai
into our test file so that we can use chai "matchers" - which we combine with the "solidity" matcher imported from "ethereum-waffle": https://ethereum-waffle.readthedocs.io/en/latest/matchers.html - now we are able to specify the exact arguments that we expect to receive from the "Transfer" event, and we can ensure that the test is actually passing as intended.
If you're wondering how we determined the arguments we expect to receive, I'll explain: First, we can inspect the _mint
method in bored-ape.sol
and see that Transfer
emits 3 arguments.
// bored-ape.sol
emit Transfer(address(0), to, tokenId);
The first argument is the "Zero account": https://ethereum.stackexchange.com/questions/13523/what-is-the-zero-account-as-described-by-the-solidity-docs - also known as "AddressZero". The second argument "to" is the address that sent the mintApe
transaction - in this case we're just using the owner's address. Lastly, the tokenId is defined within a for-loop in the mintApe
method, and is set to be equal to the return value of calling the tokenSupply
getter.
Once we know what these values are, we can input them into our withArgs
method, including a handy constant provided by the ethers library called AddressZero
:
// bored-ape.test.ts
.withArgs(ethers.constants.AddressZero, owner.address, tokenId);
And that's it - we can run npx hardhat test
and we'll get a passing test. If you change any of the values in withArgs
you'll get a failing test - exactly what we expect!
Here's what the final test file looks like:
import { expect } from "chai";
import { ethers } from "hardhat";
import chai from "chai";
import { solidity } from "ethereum-waffle";
import { beforeEach } from "mocha";
import { Contract } from "ethers";
import { SignerWithAddress } from "@nomiclabs/hardhat-ethers/signers";
chai.use(solidity);
describe("Bored Ape", () => {
let boredApeContract: Contract;
let owner: SignerWithAddress;
let address1: SignerWithAddress;
beforeEach(async () => {
const BoredApeFactory = await ethers.getContractFactory(
"BoredApeYachtClub"
);
[owner, address1] = await ethers.getSigners();
boredApeContract = await BoredApeFactory.deploy(
"Bored Ape Yacht Club",
"BAYC",
10000,
1
);
});
it("Should initialize the Bored Ape contract", async () => {
expect(await boredApeContract.MAX_APES()).to.equal(10000);
});
it("Should set the right owner", async () => {
expect(await boredApeContract.owner()).to.equal(await owner.address);
});
it("Should mint an ape", async () => {
await boredApeContract.flipSaleState();
const apePrice = await boredApeContract.apePrice();
const tokenId = await boredApeContract.totalSupply();
expect(
await boredApeContract.mintApe(1, {
value: apePrice,
})
)
.to.emit(boredApeContract, "Transfer")
.withArgs(ethers.constants.AddressZero, owner.address, tokenId);
});
});
Jackpot! Well done, we've covered all of our objectives for this tutorial:
- How to find smart contract code for specific projects
- How to add that code to a local development environment
- How to install & set-up a simple Hardhat development environment
- How to compile a contract and write tests for it
Hopefully this has given you some insight into the process of importing and testing contracts with Hardhat, Ethers, Chai & Mocha. The same processes can be followed when you write your own Solidity contracts, and when combined with a front-end repo you have the power of a complete development suite with really intuitive processes & thorough documentation.
If you'd like to view the source code for this tutorial, you can find it here: https://github.com/jacobedawson/import-test-contracts-hardhat
Thanks for playing ;)
Follow me on Twitter: https://twitter.com/jacobedawson
53