Better token

Filip Koprivec
11 min readJan 16, 2023

--

We now have a token, but it is pretty basic. Let’s add some more functionality to it.

Before we start writing the code, we should pause a bit and try to think about what we want to achieve. The token should be able to perform a few necessary functions

  • Transfer tokens from one account to another.
  • Tell how many tokens an account has.
  • Indicate the total supply of tokens.

ERC20

The more we think about this, the more we realize that some smart person already thought about this and it might be a good idea to see if we can reuse the chain of thought. The reasoning is indeed correct and we can use the ERC20 standard to implement our token in a standardized way. You can see the full description and reasoning online, but we will try to summarize it here.

We will use an OpenZeppelin library to implement the ERC20 standard.

The IERC20 should be somewhat readable by now.

Keyword interface is used to define an interface and the interface behaves like an interface in java or typescript they provide a blueprint with required methods that need to be implemented in a class. There are also abstract classes in solidity, they are an interface with some provided default implementation, but we will not use them in this tutorial.

The interface first defines two events. Events can only be emitted from solidity code and must be read from outside the blockchain, but they are extremely useful to observe the state of the blockchain and analyze the state and changes of the blockchain.

The events are Transfer and Approval with additional parameters. The first one is emitted when a transfer of tokens is performed and the second one is emitted when approval of tokes is performed. If tokens are created, it is customary to emit an event where tokens were transferred from the 0 address. If you want to know more about events, check official docs.

The other part of the interface defines the functions we expect the token to have. One should be able to get the totalSupply of tokens, the balanceOf a specific account and transfer tokens from one account to another. The first two functions are view functions. This means that they do not change the state of the blockchain and only read (view) the state of the blockchain. They can be called without any gas¹ cost using external tools like blockscout or web3 libraries, although, the gas cost is still paid when used during the transaction execution. The third one is a state modifying function that will change the state of the blockchain. We also have pure functions that neither view the state of the blockchain nor change it, they are pure mathematical functions. Keywords external, public, internal and private are used to define the visibility of the function, but we will not go into details here.

The final part of the interface defines functions that are used to approve a spender to transfer tokens from the owner account. This is especially useful when interacting with other contracts, where we want to allow some external contract or dapp to transfer tokens from our account when needed, but do not wish to do this immediately (we can usually even approve more than the current balance of the account).

Basic implementation

Let’s implement this thing.

We will create a simple token contract, that implements the ERC20 standard. There are multiple possibilities to do this:

  • We can implement the interface by looking at the code of the interface online (exercise: why is this idea not optimal).
  • We can copy and paste the interface code into some file and import it.
  • We can install the openzeppelin library and import the interface from there.

If you are using the startup repository, the openzeppelin library is already installed.

To be as close to the real world as possible, we will install the openzeppelin library and import the interface from there. Run

npm install @openzeppelin/contracts@4.7.0

which will install the openzeppelin library into the node_modules folder and enable hardhat to import contracts from there (we use a higher version of openzeppelin contracts than the FTSO system to get access to new features, but the two versions match on important parts).

Let’s write the initial contract. Create a file Token.sol in the contracts folder and add the following code:

If you are using remix, you can use "https://github.com/OpenZeppelin/openzeppelin-contracts/blob/171fa40bc8819c0393bab8e70778cf63bf08fb07/contracts/token/ERC20/IERC20.sol" as contract import path.

And run npx hardhat compile to compile the contract.

BOOM! the compilation fails (and even produces a somewhat useful error message) because the token does not implement the IERC20 interface.

Ouch :(

There is a lot of information in the error message, but the most important part is the fact, that our bellowed contract is missing a lot of methods that are defined in the IERC20 interface. While type errors can be annoying, they are also very useful, because they force us to implement the interface correctly (keep in mind, that the implementation is correct on the type level, not on the logical level, so a very lax definition of correctness²).

Let’s first check the code a bit. We first import just the IERC20 interface from the openzeppelin library. Solidity also supports so-called wildcard imports, where we can import all the contracts from a specific folder, but we will not use them in this tutorial (and they are not recommended in general, it is always better to be explicit).

We will first just implement stubs for all the methods to make them compile and then we will implement (and test) them one by one.

Yay, it compiles! But wait how can all the methods be empty, surely this makes no sense 🤔. As mentioned earlier, everything in solidity is zero-initialized by default, so an empty function always returns zero-ish variables, so we are ok with such an implementation, for now.

Let’s implement the easy totalSupply method. We can just make it return some constant value, let's say 10**14. We will see, why the number is so big later (if there are too many tokens in circulation, we can just burn them at some later point).

The function

function totalSupply() external view override returns (uint256) {}

is a view function, meaning that it can observe the state of the blockchain, but it cannot change it (it makes intuitive sense because we don't want to change the state of the blockchain by just peeking at the total amount of tokens in circulation).

For now, let’s just return 10**14 (we can also use the1e14 notation, but I prefer the first one).

function totalSupply() external view override returns (uint256) {
return 10**14;
}

This now returns a fixed amount of tokens and we can easily compile it. The compiler now produces an interesting warning:

Warning: Function state mutability can be restricted to pure

The compiler was able to infer that the function is pure (it does not read the state of the blockchain and does not change it), so we can restrict the function to be pure by replacing the view keyword with pure keyword. We won't do this, as we will need to change the function later, but it is good to know that the compiler is able to infer the function state mutability and warn us about it. Generally speaking, it is a good idea to use the most restrictive state mutability possible, as it makes the code more secure and easier to reason about and also gives the compiler more freedom to optimize the code.

The keyword override is used to tell the compiler that we are overriding a function from the interface.

And now for the hard part, let’s implement the balanceOf method. What is the best way to store the balances of the accounts? We will need some sort of mapping structure, we could use a list of pairs, but that would be very inefficient. The best way to do this in solidity is to use a mapping structure which is almost like a hash table, except that there is no easy way to iterate over the keys of the mapping. Specifically, we will need to store the list of all the accounts that have some balance in the token if we want to keep track of them. More technically, we may think of mapping as an (opaque) function from keys to values and the implementation is actually pretty close to that abstraction.

We first define a mapping from address to uint256 and then we can use it to store the balances of the accounts as a state variable outside any of the functions but inside the contract definition. To keep things concisely structured and in line with the style guide, we will define instance variables at the top of the contract. We also use _ prefix to indicate that the variable is a private instance variable.

contract Token is IERC20 {    mapping (address => uint256) private _balances;    // Other parts
}

The mapping is private, meaning that it is only accessible from inside the contract and is initially empty. We can now implement the balanceOf method by just looking up the balance of the specified argument in mapping.

function balanceOf(address account)
external
view
override
returns (uint256)
{
return _balances[account];
}

and it compiles!

Let’s implement the transfer method.

The method transfers the specified amount of tokens from the caller to the specified address, but there are a few things to consider. First of all, we need to check that the caller has enough tokens to transfer. We can do this by looking up the balance of the caller in the mapping and checking that it is greater or equal to the amount to transfer. If the caller does not have enough tokens, we should fail the transaction by reverting. Keep in mind, that revert is not the same as throwing an exception in other languages, it is a special instruction that reverts the state of the blockchain to the state before the transaction was executed. We may still catch revert on a higher level, but the state from revert to catch is undone to the state before the transaction was executed. Then we just need to update the balances of the caller and the receiver and we are done. We should also emit an event on a successful transaction so that the off-chain applications can easily react to the transfer.

We use a special msg variable to access transaction-specific data. In this case, we use msg.sender to get the address of the account/address that called the contract (this might be different than the address that started the transaction chain).

We first declare an error type, which is a special type of exception that can be thrown by the revert statement. Our error contains just enough information to be useful, but not too much to be a privacy concern. Older versions of solidity would need to use string as the error message, but newer versions allow us to use custom types, which are cheaper to use, cheaper to deploy, more flexible and easier to reason about (triple win).

If the caller does have enough tokens, we just update the balances of the caller and the receiver and return true to indicate success.

Testing

Our contract is getting bigger and we would like to add some tests to make sure that it works as expected. We already use typechain which eases the testing process a lot

The typings will automatically be generated when you run npx hardhat compile. If the typing ever get out of sync, just run npx hardhat clean to force a full recompilation next time.

And now we can write some tests. Copy the following code into test/TokenTest.ts and run npx hardhat test.

Yay, tests go okay.

To make the development a bit easier, we can force hardhat to only test a specific file. Run hardhat test tests/TokenTest.ts to only run tests in the specified file.

We first import the token contract and instance types from the typechain generated typings. Then we import the truffle artifacts’ Token and give it a type to help us.

The tests are written in the same way as before, but we add a beforeEach function, that will get called every time the tests are run and we can use it to group the initialization code together. We can now write the tests in a normal typescript code. Let's see what the assertion we will test for will be. If account A starts with X tokens, and A transfers 100 tokens to account B, then the balance of account A should be X-100 and the balance of account B should be 100.

But there is a problem, we don’t have any tokens to transfer initially. To fix this, we will assign a total supply of tokens to the account that created the contract in the constructor. To make it even more interesting, we will make the total supply configurable by the deployer.

Let’s remove the totalSupply function and add a constructor and another field to the contract.

uint256 public immutable override totalSupply;     constructor (uint256 initialSupply) {
_balances[msg.sender] = initialSupply;
totalSupply = initialSupply;
}

We use the immutable keyword (similar to final in java) to indicate that the value of the field will never change after the initialization in the constructor (this is an optimization). As the solidity compiler automatically adds a zero argument function to every public field, we can use the override keyword to indicate that we are overriding the totalSupply function from the interface and don't have to implement the function at all.

Let’s first test that the total supply works as expected and gets assigned to the owner.

Run npx hardhat test and see that the test passes. Now for the transfer, we know that owner has some balance at the start, let's try and test this in a scenario. We will generate two other addresses do some transfers and try to make sure that the balances after the transfer are correct.

The only new thing is the last argument to transfer which is an object with the from field. We use it to force the transaction to be sent from the owner's account.

Exercise: Add a test that tests a 3-party scenario, where A transfers to B, B transfers to C and C transfers some to A and some amount back to B.

Let’s test that invalid transactions raise an error.

The following code asserts, that if an error is thrown, we won’t test for a specific error message, but we could do that as well.

To make the tests even more specific, we even use .only to run just this test and ignore all others. Be careful and don't leave onlys when you want to run the full test suite.

Exercise: Add a test that tests that a transfer from an account with no balance fails. Exercise: Add a test that tests that the transfer function properly emits events.

Allowances

Let’s add the allowance functionality to the token. The idea is exactly the same as with the transfer, but we need to add a two-level mapping to store the allowances from one account to another.

mapping (address => mapping (address => uint256)) private _allowances;

Exercise: Implement the allowance, approve and transferFrom functions with corresponding tests. Try to think about the edge cases and test them.

Solution:

An example implementation of ERC20 token

Yay, you made it this far, congratulations :)

Stay tuned for the next part, where we deploy and try our contracts on a real (although test) chain.

[1]: Gas is the cost of running a transaction on the blockchain. Each operation costs a specified amount of gas and the total gas cost of the transaction is paid by the sender of the transaction. The gas cost is paid in the native currency of the blockchain. We will ignore the costs of the transactions for now but will mention them in the next parts of the tutorial.

[2]: There is a growing community of researchers that work with developing tools that try to assert, that contracts are provably correct. If you want to join them, shoot me an email.

--

--