Jumping into Solidity — The ERC721 Standard (Part 5)

At the end of my last article, we finished off our ERC721 contract. If you haven’t read my previous articles, you may want to start from Part 1 to get the whole burrito, or from Part 3 if you just want to get straight to the code. Alternately if you want to skip it all, you can find the finished product on my GitHub.

In fact, even if you brought your own ERC721 contract from home it doesn’t matter, because today we’re covering a not-very-sexy but very important part of any coding project — testing!

For some, “test” is a four letter word (so to speak), but when writing smart contracts it’s extremely important; once your contract is deployed it can’t be changed. If you make a mistake, it’ll cost your users real ETH, which has a real world value, and therefore real world consequences. And if you think there aren’t malicious actors watching every contract deployed to the Mainnet and looking for vulnerabilities, you’d be wrong!

The moral of the story is to always test your smart contracts, which is what we’re doing today.

Photo by rawpixel on Unsplash

The Set-up

When it comes to testing environments, as with most development set-ups, there are many ways to skin a cat. If you have your own way of doing things, I’ll try to keep this useful by breaking test cases into subheadings and adding a little more information when needed, but a passing knowledge of Javascript is recommended.

Today I’ll be using NodeJS, with the Mocha test framework. We’ll also be making use of Web3 and ganache-cli. Once you’ve installed NodeJS from the link provided, navigate to your project directory and run the command:

npm init --full

It’ll ask you a bunch of questions, just say yes to everything (it doesn’t matter if you’re just doing this for testing). You should now have a package.json file which we’ll edit in a minute. The rest of the modules can be installed with the following command (when in your project directory):

npm install --save mocha ganache-cli web3

You now want to edit your package.json file and change the line that looks something like this:

"test": "echo \"Error: no test specified\" && exit 1"

to this:

"test": "mocha"

Lets create two subdirectories within our project directory too,

{your project directory}/test

and

{your project directory}/contracts

In the contracts folder, I have compiled versions of all my contracts in the form of .json files, so for the purposes of this article you should at least have the following files in there:

  • TokenERC721.json
  • ValidReceiver.json
  • InvalidReceiver.json

We covered the receivers way back in Part 2, so if you don’t have them any more you can find them there. As this isn’t an introductory Solidity series, I’m going to assume you know how to compile your contracts. But for reference, I use Solc.

Lastly, create a new file Token.test.js in your /test directory. This is where the magic will happen. We have to declare a bunch of stuff at the start and it’s all pretty self explanatory if you’ve used NodeJS before, so here it is:

const assert = require('assert');
const ganache = require('ganache-cli');
const Web3 = require('web3');
const provider = ganache.provider({
gasLimit: 10000000
});

const web3 = new Web3(provider);

const compiledToken = require('../contracts/TokenERC721.json');
const compiledValidReceiver = require('../contracts/ValidReceiver.json');
const compiledInvalidReceiver = require('../contracts/InvalidReceiver.json');

Note I set the block gas limit to 10 million. In the past I’ve had troubles when testing because ganache-cli gets a bit confused and tries to deploy a bunch of contract instances in the same “block”. We aren’t testing gas usage today anyway so it doesn’t affect our tests.

If you’ve never used Mocha before, we’re able to use beforeEach to run a bit of code before each test case. What we’re going to do is deploy a fresh new contract for each test — that means the results of one test one be affected by the one before it. We also declare accounts, token and initialTokens so they’re in the global scope and we can access them later.

let accounts;
let token;
const initialTokens = 10;
beforeEach(async () => {
accounts = await web3.eth.getAccounts();

token = await new web3.eth.Contract(JSON.parse(compiledToken.interface))
.deploy({
data: compiledToken.bytecode,
arguments: [initialTokens]
})
.send({from: accounts[0], gas:'8000000'});
token.setProvider(provider);
});

The remainder of our test file will fall within a describe function, and our test cases will take the form of the example below:

describe('Token Contract',() => {
//All our test cases go here

it('Example test case', () => {
assert(true);
});
});

At any time, you can run your test file by navigating to your project’s main directory and running the command:

npm run test

The tests

I’m just going to blast through these. You’ll notice I use async and await fairly often. It’s just a way of making asynchronous function calls a little more synchronous. It slows things down a bit, but for our testing purposes today it doesn’t matter. Anyway, here we go…

Balance of creator == initial token supply

Note: This test case is specific to my token design. As discussed in Part 3, the standard doesn’t care how your tokens are created. I was tempted to leave this out because it’s not actually part of the standard, but I think those of you who have been playing along since the beginning will benefit from it. The same goes for the issue and burn tests below.

it('Balance of creator == initial token supply', async () => {
const balance = await token.methods.balanceOf(accounts[0]).call();
assert(balance == initialTokens);
});

Creator can issue tokens

it('Creator can issue tokens', async () => {
const toIssue = 2;
const owner = accounts[0];
await token.methods.issueTokens(toIssue).send({
from: owner
});
const finalBalance = await token.methods.balanceOf(accounts[0]).call();
assert((initialTokens + toIssue) == finalBalance);
});

Can burn token

it('Can burn token', async () => {
const owner = accounts[0];
await token.methods.burnToken('1').send({
from: owner
});
const finalBalance = await token.methods.balanceOf(accounts[0]).call();
assert((initialTokens - 1) == finalBalance);
});

Can transferFrom your own coin

it('Can transferFrom your own coin', async () => {
const tokenId = 1;
const owner = accounts[0];
const operator = accounts[1];
const receiver = accounts[2];

try{
await token.methods.transferFrom(owner, receiver, tokenId).send({
from: owner
});
assert(true);
}catch(err){
assert(false);
}
});

Can safeTransferFrom your own coin to person

it('Can safeTransferFrom your own coin to person', async () => {
const tokenId = 1;
const owner = accounts[0];
const receiver = accounts[1];
let gotReceiver;
try{
await token.methods.safeTransferFrom(owner, receiver, tokenId).send({
from: owner
});
assert(true);
}catch(err){
assert(false);
}
gotReceiver = await token.methods.ownerOf(tokenId).call();
assert(gotReceiver == receiver);

});

Can safeTransferFrom your own coin to valid contract

This is where we use our ValidReceiver contract. Deploy that contract and send a token to it. If the transaction doesn’t fail and the contract is the new owner, then this test passes.

it('Can safeTransferFrom your own coin to valid contract', async () => {
const tokenId = 1;
const owner = accounts[0];
let gotReceiver;
const receiver = await new web3.eth.Contract(JSON.parse(compiledValidReceiver.interface))
.deploy({
data: compiledValidReceiver.bytecode
})
.send({from: accounts[0], gas:'1000000'});
receiver.setProvider(provider);
const receiverAddress = receiver.options.address;

try{
await token.methods.safeTransferFrom(owner, receiverAddress, tokenId).send({
from: owner
});
assert(true);
}catch(err){
assert(false);
}
gotReceiver = await token.methods.ownerOf(tokenId).call();
assert(gotReceiver == receiverAddress);

});

Can’t safeTransferFrom your own coin to invalid contract

Similar to the last test, but we deploy our InvalidReceiver and when we send a token to it we expect the transaction to fail.

it('Can\'t safeTransferFrom your own coin to invalid contract', async () => {
const tokenId = 1;
const owner = accounts[0];
const receiver = await new web3.eth.Contract(JSON.parse(compiledInvalidReceiver.interface))
.deploy({
data: compiledInvalidReceiver.bytecode
})
.send({from: accounts[0], gas:'1000000'});
receiver.setProvider(provider);
const receiverAddress = receiver.options.address;

let success = false;
try{
await token.methods.safeTransferFrom(owner, receiverAddress, tokenId).send({
from: owner
});
success = true;
}catch(err){
}
assert(!success);
});

Can safeTransferFrom coin with data

it('Can safeTransferFrom coin with data', async () => {
const tokenId = 1;
const owner = accounts[0];
const receiver = accounts[1];
let gotReceiver;

const bytes = web3.utils.asciiToHex("TEST");

try{
await token.methods.safeTransferFrom(owner, receiver, tokenId, bytes).send({
from: owner
});
assert(true);
}catch(err){
assert(false);
}
gotReceiver = await token.methods.ownerOf(tokenId).call();
assert(gotReceiver == receiver);
});

Can approve someone for your own token

it('Can approve someone for your own token', async () => {
const tokenId = 1;
try{
await token.methods.approve(accounts[1],tokenId).send({
from: accounts[0]
});
assert(true);
}catch(err){
assert(false);
}
});

Can’t approve someone for someone else’s token

it('Can\'t approve someone for someone else\'s token', async () => {
const tokenId = 1;
let success = false;
try{
await token.methods.approve(accounts[2],tokenId).send({
from: accounts[1]
});
success = true;
}catch(err){
}
assert(!success);
});

Person gets approved

it('Person gets approved', async () => {
const tokenId = 1;
let approved;
await token.methods.approve(accounts[1],tokenId).send({
from: accounts[0]
});
approved = await token.methods.getApproved(tokenId).call();
assert(approved == accounts[1]);
});

New approved overwrites old one

it('New approved overwrites old one', async () => {
const tokenId = 1;
let approved0, approved1;
await token.methods.approve(accounts[1],tokenId).send({
from: accounts[0]
});
approved0 = await token.methods.getApproved(tokenId).call();
await token.methods.approve(accounts[2],tokenId).send({
from: accounts[0]
});
approved1 = await token.methods.getApproved(tokenId).call();

assert(approved1 == accounts[2]);
});

Can un-approve (set to 0x0)

Make sure 0x0 can be set as the approved address after someone else was approved.

it('Can un-approve (set to 0x0)', async () => {
const tokenId = 1;
let approved0, approved1;
await token.methods.approve(accounts[1],tokenId).send({
from: accounts[0]
});
approved0 = await token.methods.getApproved(tokenId).call();
await token.methods.approve(0x0,tokenId).send({
from: accounts[0]
});
approved1 = await token.methods.getApproved(tokenId).call();

assert(approved1 == 0x0);
});

Approved can transfer coin

Approve someone for a coin, and then transfer that coin to someone else from the approved address.

it('Approved can transfer coin', async () => {
const tokenId = 1;
const owner = accounts[0];
const approved = accounts[1];
const receiver = accounts[2];

await token.methods.approve(approved,tokenId).send({
from: owner
});
try{
await token.methods.transferFrom(owner,receiver,tokenId).send({
from: approved,
gas: '1000000'
});
assert(true);
}catch(err){
assert(false);
}
});

After sending, no longer approved

Make sure approval clears after a token is transferred.

it('After sending, no longer approved', async () => {
const tokenId = 1;
const owner = accounts[0];
const approved = accounts[1];
const receiver = accounts[2];

let gotApproved;

await token.methods.approve(approved,tokenId).send({
from: owner,
gas: '10000000'
});
await token.methods.transferFrom(owner,receiver,tokenId).send({
from: approved,
gas: '10000000'
});
gotApproved = await token.methods.getApproved(tokenId).call();
assert(approved != gotApproved);
});

Can make someone operator

it('Can make someone operator', async () => {
const owner = accounts[0];
const operator = accounts[1];
let isOperator;
await token.methods.setApprovalForAll(operator,true).send({
from: owner
});
isOperator = await token.methods.isApprovedForAll(owner, operator).call();

assert(isOperator);
});

Can unmake someone operator

Make sure if you make someone an operator, you can revoke this privilege.

it('Can unmake someone operator', async () => {
const owner = accounts[0];
const operator = accounts[1];
let isOperator;
await token.methods.setApprovalForAll(operator,true).send({
from: owner
});
await token.methods.setApprovalForAll(operator,false).send({
from: owner
});
isOperator = await token.methods.isApprovedForAll(owner, operator).call();
assert(!isOperator);
});

Operator can send coin

it('Operator can send coin', async () => {
const tokenId = 1;
const owner = accounts[0];
const operator = accounts[1];
const receiver = accounts[2];
let gotReceiver;
await token.methods.setApprovalForAll(operator,true).send({
from: owner
});
try {
await token.methods.transferFrom(owner, receiver, tokenId).send({
from: operator
});
}catch(err){
assert(false);
}
gotReceiver = await token.methods.ownerOf(tokenId).call();
assert(receiver == gotReceiver);
});

After operator sends a token, operator can’t send it again

Confirm that operator privileges don’t follow a token.

it('After sending token, operator can\'t send again', async () => {
const tokenId = 1;
const owner = accounts[0];
const operator = accounts[1];
const receiver = accounts[2];
let gotReceiver;
await token.methods.setApprovalForAll(operator,true).send({
from: owner
});
await token.methods.transferFrom(owner, receiver, tokenId).send({
from: operator
});
let success = false;
try{
await token.methods.transferFrom(receiver, owner, tokenId).send({
from: operator
});
success = true;
}catch(err){
}
assert(!success);
});

Wrapping up

And that’s all our tests written! If you were using Mocha, make sure all your it() functions are contained within the describe() function. If not, I hope you still found this useful.

If you’re lazy or just want to make sure you didn’t mess anything up, you can find the full test file on my GitHub. I know this article was mostly just cut and paste code with very little explanation, but I think the test case names were pretty self explanatory.

So now we have our basic ERC721 implementation, and we’ve tested it so we know it works. In the next article we’ll be looking into the Metadata and Enumerable extensions, including a very fresh change to the Metadata standard extension which I’m proud to say I added last week. (That’s right folks, your humble author is a contributor for the ERC721 Standard!)

Next: Jumping into Solidity — The ERC721 Standard (Part 6)

Like what you read? Give Andrew Parker a round of applause.

From a quick cheer to a standing ovation, clap to show how much you enjoyed this story.