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.
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!)