Vincent A Saulys' Blog
Test Driven Development in DeFi & Crypto (part 2)
Tags: crypto defi
September 28, 2021

Part 1 is here.

In the last part, we detailed why you write specs for crypto projects, what they should look like, and why Test-Driven-Development (TDD) is probably a good idea. In this post, we'll work through a real project.

Writing your tests for Smart Contracts.

To reiterate from part 1, writing tests will turn our technical spec into something concrete and actualized. We describe, in real code, how the smart contract will get used and will ensure it works as expected. Tests should be written from the perspective of the end user. That is, how the contract actually gets called in real life scenarios. Your spec will have the details of what those real life scenarios are. Because of this, we divide our tests into three categories: no accounts, one account, or multiple accounts. [1]

For writing tests in Solidity, the programming language of Ethereum, we'll use the Truffle suite. It's emerged as an industry standard for writing ethereum smart contracts. Installation is fairly straightforward and involves npm and NodeJS. The framework is closely related to Mocha and Chai so it should be familiar to NodeJS developers.

For the purposes of this tutorial, it will be assumed you know how to use Truffle and Solidity. They also have an excellent tutorial on their site.

# installing truffle and so on
# assuming you have nodejs installed
npm install --global truffle
mkdir my-erc20-coin
cd my-erc20-coin
truffle init

This will create a series of folders and files. The only boilerplate you'll need is a migration script to deploy the coin during testing.

const VCoin = artifacts.require("VCoin"); // my name for my ERC20 coin

module.exports = function (deployer) {
  deployer.deploy(VCoin);
};

This is all boiler plate code so far. You'll then write tests in the test/ folder while typing truffle test to test them. Sometimes it helps to call ganache-cli and keep that running in the background. This simulate a fully working ethereum blockchain.

There is a bit of jank here because tests are written in javascript and contracts written in solidity. [3] Given that most smart contracts have to interface with web3.js at some point, it's close to how end users will use your application anyway.

There's a split in philosophy on whether to write all tests ahead of time or write them as you go along. If you have a technical spec ahead of time, you can probably write them one at a time. This puts you in a groove of test-code-check as you do your double-entry-coding. For this example, all tests were written ahead of time. Matter of preference.

Tests with no accounts

Now let's start writing some actual test code. We'll start with tests that don't rely on any accounts.

Let's write out boiler plate for testing. The convention is to use the suffix .spec.js for naming our tests. Naturally, we'll put them into the test folder.

const VCoin = artifacts.require("VCoin"); // this is auto-deployed when running in the console
const { assert, expect, should } = require("chai");
const truffleAssert = require("truffle-assertions");

contract("VCoin", async (accounts) => {
  describe("tests with no accounts", () => {
    beforeEach(async () => {
      this.token = await VCoin.deployed();
    });
    // ... we'll insert tests here
  });
});

This has no tests at the moment. Importing some values like assert from chai helps with syntax highlighting in Emacs. You'll need to install this separately at the project level (npm i chai) and its entirely optional.

Let's start with a simple example:

const VCoin = artifacts.require("VCoin"); // this is auto-deployed in the console
const { assert, expect, should } = require("chai");
const truffleAssert = require("truffle-assertions");

contract("VCoin", async (accounts) => {
  describe("tests with no accounts", () => {
    beforeEach(async () => {
      this.token = await VCoin.deployed();
    });

    it("should return right value for name()", async () => {});
  });
});

We create a simple english statement that says what our test should do: return the name of the coin. This keeps with the technical spec:

#### name

Returns the name of the token - e.g. `"MyToken"`.

OPTIONAL - This method can be used to improve usability,
but interfaces and other contracts MUST NOT expect these values to be present.


\`\`\` js
function name() public view returns (string)
\`\`\`

You'll notice a describe statement here. This helps keep the code separated into "clumps." Separating out how many involve multiple accounts, one account, or no accounts is done because this affects the beforeEach statements that setup the tests. Know that unlike in Jest and most NodeJS testing libraries, these are not run in parallel but instead run serially so they take longer.

You may also notice that every function is declared as async. This is a preference as .then(...).catch(...) chaining can be used instead but gets hairy when we write multiple account tests. Stick to async / await to keep the syntax consistent.

Then we'll flesh out exactly what the test does.

// ...excised
    it("should return right value for name()", async () => {
      const { token } = this;
      const name = await token.name.call();
      assert.equal(name, "VCoin", "name() should return VCoin");
    });
// ...excised

Theres a lot of docs on writing tests here but there is an important difference in that functions can be .call(...) or .sendTransaction(...). The former will get a return value we can inspect while the latter will return block information on the transaction and is needed to actually make a transaction record into the blockchain. Normally your functions will figure out which is appropriate but explicitly defining them will help avoid confusion on if your tests are behaving correctly (an achilles heel in TDD). This guide will mix the two at times to ensure that the right value is returned in addition to ensuring the function behaves correctly.

Which we'll attempt to run and get an error. Now we need to write the corresponding code. Before we get to that, we need to write a blank contract that overrides all the functions otherwise it will not compile. This is because Solidity expects all interface functions to be overwritten and will not compile until this is solved.

pragma solidity =0.8.0;

import "./interfaces/IERC20.sol";

contract VCoin is IERC20 {

    // spacing here is deliberate so its clear what goes into a
    // function (modifiers, etc.)

    function name() public override view returns (string memory) {
    }
    function symbol() public override view returns (string memory) {
    }
    function decimals() public override view returns (uint8) {
    }
    function totalSupply() public override view returns (uint256) {
    }


    function balanceOf(address _owner) public override view returns (uint256 balance) {
    }

    function approve(address _spender, uint256 _value)
        public
        override
        returns (bool success) {
    }

    function allowance(address _owner, address _spender)
        public
        override
        view
        returns (uint256 remaining) {
    }

    function transfer(address _to, uint256 _value)
        public
        override
        returns (bool success) {
    }

    function transferFrom(address _from, address _to, uint256 _value)
        public
        override
        returns (bool success) {
    }
}

We then write out the one function to solve this

// ...excised
    string private _name = "VCoin";

    function name() public override view returns (string memory) {
        return _name;
    }
// ...

Which we can test with truffle test and it will pass!

Let's finish all the remaining tests for no accounts:

// ...excised
    it("should return right value for name()", async () => {
      const { token } = this;
      const name = await token.name.call();
      assert.equal(name, "VCoin", "name() should return VCoin");
    });

    it("should return right value for symbol()", async () => {
      const { token } = this;
      const symbol = await token.symbol.call();
      assert.equal(symbol, "VC", "symbol() should return VC");
    });

    it("should return right value for decimals()", async () => {
      const { token } = this;
      const decimals = await token.decimals.call();
      assert.equal(decimals, 8, "decimals() should return 8");
    });

    it("should return right value for totalSupply()", async () => {
      const { token } = this;
      const totalSupply = await token.totalSupply.call();
      assert.equal(totalSupply, 1000000, "totalSupply() should return 1000000");
    });
//

Next, we'll write the corresponding contract functions.

    string private _name = "VCoin";
    string private _symbol = "VC";
    uint8 private _decimals = 8;  // default 'uint' is 'uint256'
    uint256 private _totalSupply = 1000000; // million

    function name() public override view returns (string memory) {
        return _name;
    }
    function symbol() public override view returns (string memory) {
        return _symbol;
    }
    function decimals() public override view returns (uint8) {
        return _decimals;
    }
    function totalSupply() public override view returns (uint256) {
        return _totalSupply;
    }

This test should pass:

  Contract: VCoin
    tests with no accounts
      ✓ should return right value for name() (109ms)
      ✓ should return right value for symbol() (86ms)
      ✓ should return right value for decimals() (56ms)
      ✓ should return right value for totalSupply() (52ms)

Tests with one account

Going back our spec, there's nothing there about how the values should be distributed at first mint. These decisions should be agreed upon in advance but the writers of the EIP-20 spec left this up to the implementor. For our purposes, let's assign it all to the one account. The corresponding test will look as follows:

//... excised
  describe("tests with one account", () => {
    beforeEach(async () => {
      this.token = await VCoin.deployed();
      this.baseAct = accounts[0];
    });

    it("should return right value for accounts[0] at start", async () => {
      const { token, baseAct } = this;
      let baseActBalance = await token.balanceOf.call(baseAct);
      let totalSupply = await token.totalSupply.call();

      // get out the actual values
      baseActBalance = baseActBalance.words[baseActBalance.length - 1];
      totalSupply = totalSupply.words[totalSupply.length - 1];

      assert.equal(
        baseActBalance,
        totalSupply,
        `accounts[0] should have ${totalSupply} at start but has ${baseActBalance}`
      );
    });
//...

Note that this test calls two functions: the balanceOf and constructor. That means our object will need two functions to pass this test. First we assign all values to the account which deploys the contract -- accounts[0] in this case -- and then we implement balanceOf to check it.

This breaks unit testing which would write tests for each individual function. How would you do that here? It's not entirely clear. If the functions are not called indepentantly of each other it feels silly to worry about these functions being completely separated. The constructor's operations can only be confirmed by the balanceOf function. Rather, we want to think back to the end user and how they will use this contract. This test aims to approximate that as much as possible.

//... excised

    mapping(address => uint256) private _accountToBalance;

    constructor() {
        _accountToBalance[msg.sender] = _totalSupply;
    }

    function balanceOf(address _owner) public override view returns (uint256 balance) {
        return _accountToBalance[_owner];
    }
//

In addition to those functions, we need a mapping (think hashmap or python dictionary) to track each address' balance of $VCoin. This variable is private and so, under the paradigm described here, not really tested. [4]

The other test involving one account will be that accounts with no balance return as 0.

//... excised
    it("should return 0 if account doesn't exist", async () => {
      const { token, otherAct } = this;  // this.otherAct = accounts[3];
      const otherActBalance = await token.balanceOf.call(otherAct);
      assert.equal(
        otherActBalance,
        0,
        "accounts[1] should have balance of zero at start"
      );
    });
//

Here, we'll run the test and it will pass. That's lucky, sometimes the default behavior is as expected for our test.

The final tally looks as follows:

vincent:vcoin-erc20-ex$ truffle test
Using network 'development'.


Compiling your contracts...
===========================
> Everything is up to date, there is nothing to compile.



  Contract: VCoin
    tests with no accounts
      ✓ should return right value for name() (76ms)
      ✓ should return right value for symbol() (60ms)
      ✓ should return right value for decimals() (58ms)
      ✓ should return right value for totalSupply() (63ms)
    tests with one account
      ✓ should return right value for accounts[0] at start (156ms)
      ✓ should return 0 if account doesn't exist (62ms)

Tests with More than One Account

Next, we need to move to tests with multiple accounts.

//... excised
  describe("tests with two accounts", () => {
    beforeEach(async () => {
      this.token = await VCoin.deployed();
      this.baseAct = accounts[0];
      this.otherAct = accounts[5];
    });
  });
//... 

Going back our spec, we need to think about the scenarios users will call this. This will guide what tests we write.

For one, we have an approve function. How might this get used in practice? Going back to the spec, we can start to think about this:

This spec calls for allowing the caller to approve funds for _spender. What scenarios might this come up? Let's turn it into tests but first in plain english to keep it high level before we write the tests themselves.

it("should allow an account to approve if it has funds", async () => {});
it("should return false on approve if account doesn't have funds", async () => {});

Good start but let's go back to the spec. Anything else to cover?

#### approve

Allows `_spender` to withdraw from your account multiple times, up to
the `_value` amount. If this function is called again it overwrites
the current allowance with `_value`. 

**NOTE**: To prevent attack vectors like the one [described here](https://docs.google.com/document/d/1YLPtQxZu1UAvO9cZ1O2RPXBbT0mooh4DYKjA_jp-RLM/) and discussed [here](https://github.com/ethereum/EIPs/issues/20#issuecomment-263524729),
clients SHOULD make sure to create user interfaces in such a way that they set the allowance first to `0` before setting it to another value for the same spender.
THOUGH The contract itself shouldn't enforce it, to allow backwards compatibility with contracts deployed before

\`\`\` js
function approve(address _spender, uint256 _value) public returns (bool success)
\`\`\`

For one, it needs to overwrite the existing amount. This will be another test to write:

it("should allow for approve to be called twice and overwritten", async () => {});

Another aspect to keep in mind is that there is an Approval event specified down the page. This needs to also be tested for.

#### Approval

MUST trigger on any successful call to `approve(address _spender, uint256 _value)`.

\`\`\` js
event Approval(address indexed _owner, address indexed _spender, uint256 _value)
\`\`\`

That should cover all the tests here. Note it makes mention to an attack vector that was discussed but puts the burden on the client end-user. That's their problem and therefore one we won't be testing for though you potentially could. This is an example of including things an application will not do.

Our final tests -- much longer than before:

//... excised
  describe("tests with two accounts", () => {
    beforeEach(async () => {
      this.token = await VCoin.deployed();
      this.baseAct = accounts[0];
      this.otherAct = accounts[5];
    });

    it("should allow an account to approve if it has funds", async () => {
      const { token, baseAct, otherAct } = this;
      const approvalAmt = 1;

      const success = await token.approve.call(otherAct, approvalAmt);
      assert.equal(
        success,
        true,
        "baseAct should approve transfer to otherAct"
      );
      let tx = await token.approve.sendTransaction(otherAct, approvalAmt);
      truffleAssert.eventEmitted(tx, "Approval", (ev) => {
        return (
          ev._owner == baseAct &&
          ev._spender == otherAct &&
          ev._value == approvalAmt
        );
      });
    });

    it("should return false on approve if account doesn't have funds", async () => {
      const { token, baseAct, otherAct } = this;
      const baseActBalance = await token.balanceOf.call(baseAct);
      const approvalAmt = baseActBalance + 1;

      const success = await token.approve.call(otherAct, approvalAmt);
      assert.equal(
        success,
        false,
        "baseAct should not be able to transfer more than it has"
      );
      let tx = await token.approve.sendTransaction(otherAct, approvalAmt);
      truffleAssert.eventNotEmitted(tx, "Approval");
    });

    it("should allow for approve to be called twice and overwritten", async () => {
      const { token, baseAct, otherAct } = this;
      const baseActBalance = await token.balanceOf.call(baseAct);
      const approvalAmt = 15;
      const secondApprovalAmt = 24;

      let success = await token.approve.call(otherAct, approvalAmt);
      assert.equal(success, true);
      let tx = await token.approve.sendTransaction(otherAct, approvalAmt);
      truffleAssert.eventEmitted(tx, "Approval");
      let allowance = await token.allowance.call(baseAct, otherAct);
      assert.equal(
        allowance,
        approvalAmt,
        `incorrect allowance returned: ${allowance} and ${approvalAmt}`
      );

      success = await token.approve.call(otherAct, secondApprovalAmt);
      assert.equal(success, true);
      tx = await token.approve.sendTransaction(otherAct, secondApprovalAmt);
      truffleAssert.eventEmitted(tx, "Approval");
      allowance = await token.allowance.call(baseAct, otherAct);
      assert.equal(
        allowance,
        secondApprovalAmt,
        `incorrect allowance returned: ${allowance} and ${secondApprovalAmt}`
      );
    });
//...

Then we'll write the corresponding solidity code for approve:

//...excised
    function approve(address _spender, uint256 _value)
        public
        override
        returns (bool success) {
        if (_accountToBalance[msg.sender] < _value)
            return false;
        _accountToApprovals[msg.sender][_spender] = _value;
        emit Approval(msg.sender, _spender, _accountToApprovals[msg.sender][_spender]);
        return true;
    }
//...

This will not pass any of the tests. They also call an allowance function which we need to write:

// ... excised
    function allowance(address _owner, address _spender)
        public
        override
        view
        returns (uint256 remaining) {
        return _accountToApprovals[_owner][_spender];
    }
// ... 

We then write further test, starting with their descriptors:

it("should return the full amount approved if an account asks for this right after approve", async () => {});
it("should return 0 for allowance if no approve was called", async () => {});

Then we flesh out the full tests:

//...excised
    it("should return the full amount approved if an account asks for this right after approve", async () => {
      const { token, baseAct, otherAct } = this;
      const baseActBalance = await token.balanceOf.call(baseAct);
      const approvalAmt = 11;

      const success = await token.approve.call(otherAct, approvalAmt);
      assert.equal(success, true);
      let tx = await token.approve.sendTransaction(otherAct, approvalAmt);
      truffleAssert.eventEmitted(tx, "Approval");

      const allowance = await token.allowance.call(baseAct, otherAct);
      assert.equal(allowance, approvalAmt, "incorrect allowance returned");
    });

    it("should return 0 for allowance if no approve was called", async () => {
      const { token, baseAct } = this;

      let allowed = await token.allowance.call(baseAct, accounts[3]);
      assert.equal(
        allowed,
        0,
        "there should be zero allowance when no approve was called"
      );
    });
//...

Our next function is to transfer between two accounts -- (Sounding like a broken record) -- when would this get called?

it("should throw if amount is larger than the user has", async () => {});
it("`transfer()` should work with or without approval", async () => {});
it("should successfully transfer if approve is called first & have correct allowance() for `transfer()`", async () => {});

Sidebar: something not clear in the spec (!) is whether the transfer() function should be tied to the approvals like transferFrom(). The answer is No but I only found this out when I peeked at the Uniswap source code.

We'll also need to keep in mind that a transfer of zero should also emit a transfer event per the spec:

#### transfer

Transfers `_value` amount of tokens to address `_to`, and MUST fire the `Transfer` event.
The function SHOULD `throw` if the message caller's account balance does not have enough tokens to spend.

*Note* Transfers of 0 values MUST be treated as normal transfers and fire the `Transfer` event.

\``` js
function transfer(address _to, uint256 _value) public returns (bool success)
\```
it("a `transfer` of zero is treated normally with Transfer event", async () => {});

The tests will be as follows:

// ... 
    it("should throw if amount is larger than the user has", async () => {
      const { token, baseAct, otherAct } = this;
      const transferAmt = (await token.balanceOf.call(baseAct)) + 1;
      await truffleAssert.fails(token.transfer(otherAct, transferAmt));
    });

    it("`transfer()` should work with or without approval", async () => {
      const { token, baseAct, otherAct } = this;
      const baseActBalance = await token.balanceOf.call(baseAct);
      const otherActBalance = await token.balanceOf.call(otherAct);
      const transferAmt = 10;

      let success = await token.transfer.call(otherAct, transferAmt);
      let tx = await token.transfer.sendTransaction(otherAct, transferAmt);
      assert.equal(
        success,
        true,
        "transfer should successfully return with or without approval"
      );
      truffleAssert.eventEmitted(tx, "Transfer", (ev) => {
        return (
          ev._from == baseAct && ev._to == otherAct && ev._value == transferAmt
        );
      });

      const baseActBalanceAfter = await token.balanceOf.call(baseAct);
      const otherActBalanceAfter = await token.balanceOf.call(otherAct);

      assert.equal(
        baseActBalance - transferAmt,
        baseActBalanceAfter,
        "base account balance is incorrect after transfer"
      );
      assert.equal(
        Number(otherActBalance) + transferAmt,
        otherActBalanceAfter,
        "other account balance is incorrect after transfer"
      );
    });

    it("should successfully transfer if approve is called first & have correct allowance() for `transfer()`", async () => {
      const { token, baseAct, otherAct } = this;
      const baseActBalance = await token.balanceOf.call(baseAct);
      const otherActBalance = await token.balanceOf.call(otherAct);
      const approvalAmt = 15;
      const transferAmt = 10;

      let approval = await token.approve.call(otherAct, approvalAmt);
      assert.equal(approval, true);
      let tx = await token.approve.sendTransaction(otherAct, approvalAmt);
      truffleAssert.eventEmitted(tx, "Approval");

      let success = await token.transfer.call(otherAct, transferAmt);
      tx = await token.transfer.sendTransaction(otherAct, transferAmt);
      assert.equal(
        success,
        true,
        "transfer should successfully return after approval"
      );
      truffleAssert.eventEmitted(tx, "Transfer", (ev) => {
        return (
          ev._from == baseAct && ev._to == otherAct && ev._value == transferAmt
        );
      });

      const baseActBalanceAfter = await token.balanceOf.call(baseAct);
      const otherActBalanceAfter = await token.balanceOf.call(otherAct);

      assert.equal(
        baseActBalance - transferAmt,
        baseActBalanceAfter,
        "base account balance is incorrect after transfer"
      );
      assert.equal(
        Number(otherActBalance) + transferAmt,
        otherActBalanceAfter,
        "other account balance is incorrect after transfer"
      );

      const remainingAllowance = await token.allowance.call(baseAct, otherAct);
      assert.equal(
        approvalAmt - transferAmt,
        remainingAllowance,
        "remaining allowance is not correct"
      );
    });
    it("a `transfer` of zero is treated normally with Transfer event", async () => {
      const { token, baseAct, otherAct } = this;
      const transferAmt = 0;
      let tx = await token.transfer.sendTransaction(otherAct, transferAmt);
      truffleAssert.eventEmitted(tx, "Transfer", (ev) => {
        return (
          ev._from == baseAct && ev._to == otherAct && ev._value == transferAmt
        );
      });
    });

// ...

Note that we call an balanceOf function after already testing for it. This breaks with TDD somewhat, which insists on unit testing, but keeps it more consistent with how an end user would utilize the smart contract. This is a more "realistic" test as a result. We've already tested for balanceOf earlier so relying on it here would be a problem: any errors it returns will be caught by the earlier test.

Now we can write the corresponding code for transfer -- note that use reverts as is called for in the spec:

// ... excised
    function transfer(address _to, uint256 _value)
        public
        override
        returns (bool success) {
        if (_accountToBalance[msg.sender] < _value)
            revert("Not enough funds to transfer");
        if (_accountToApprovals[msg.sender][_to] < _value)
            return false;
        // in the past we needed to worry about buffer overflows
        // but since Solidity 0.8.0 we do not        
        _accountToBalance[msg.sender] -= _value;
        _accountToBalance[_to] += _value;
        emit Transfer(_from, _to, _value);
        _accountToApprovals[msg.sender][_to] -= _value;
        return true;
    }
//

We then rehash some of these tests for transferFrom, which works similarly to transfer:

// ... excised
    it("should not transfer if approve is not called first for `transferFrom()`", async () => {
      const { token, baseAct, otherAct } = this;
      const transferAmt = 10;

      let success = await token.transferFrom.call(
        baseAct,
        otherAct,
        transferAmt
      );
      assert.equal(
        success,
        false,
        "should not be able to transfer if we have not approved yet"
      );
      let tx = await token.transferFrom.sendTransaction(
        baseAct,
        otherAct,
        transferAmt
      );
      truffleAssert.eventNotEmitted(tx, "Transfer");
    });

    it("should successfully transfer if approve is called first & have correct allowance() for `transferFrom()`", async () => {
      const { token, baseAct, otherAct } = this;
      const baseActBalance = await token.balanceOf.call(baseAct);
      const otherActBalance = await token.balanceOf.call(otherAct);
      const approvalAmt = 15;
      const transferAmt = 10;

      let approval = await token.approve.call(otherAct, approvalAmt);
      assert.equal(approval, true);
      let tx = await token.approve.sendTransaction(otherAct, approvalAmt);
      truffleAssert.eventEmitted(tx, "Approval");

      let success = await token.transferFrom.call(
        baseAct,
        otherAct,
        transferAmt
      );
      assert.equal(
        success,
        true,
        "transfer should successfully return after approval"
      );
      tx = await token.transferFrom.sendTransaction(
        baseAct,
        otherAct,
        transferAmt
      );
      truffleAssert.eventEmitted(tx, "Transfer", (ev) => {
        return (
          ev._from == baseAct && ev._to == otherAct && ev._value == transferAmt
        );
      });

      const baseActBalanceAfter = await token.balanceOf.call(baseAct);
      const otherActBalanceAfter = await token.balanceOf.call(otherAct);

      assert.equal(
        baseActBalance - transferAmt,
        baseActBalanceAfter,
        "base account balance should is incorrect after transfer"
      );
      assert.equal(
        Number(otherActBalance) + transferAmt,
        otherActBalanceAfter,
        "other account balance should is incorrect after transfer"
      );

      const remainingAllowance = await token.allowance.call(baseAct, otherAct);
      assert.equal(
        approvalAmt - transferAmt,
        remainingAllowance,
        "remaining allowance is not correct"
      );
    });

    it("should emit Transfer event if approve is called first for `transferFrom()`", async () => {
      const { token, baseAct, otherAct } = this;
      const approvalAmt = 15;
      const transferAmt = 10;

      let approval = await token.approve.call(otherAct, approvalAmt);
      assert.equal(approval, true);
      let tx = await token.approve.sendTransaction(otherAct, approvalAmt);
      truffleAssert.eventEmitted(tx, "Approval");

      let success = await token.transferFrom.call(
        baseAct,
        otherAct,
        transferAmt
      );
      assert.equal(success, true);
      tx = await token.transferFrom.sendTransaction(
        baseAct,
        otherAct,
        transferAmt
      );
      truffleAssert.eventEmitted(tx, "Transfer", (ev) => {
        return (
          ev._from == baseAct && ev._to == otherAct && ev._value == transferAmt
        );
      });
    });
  });
// ... 

And the corresponding transferFrom solidity code:

// ... 
    function transferFrom(address _from, address _to, uint256 _value)
        public
        override
        returns (bool success) {
        if (_accountToBalance[_from] < _value)
            revert("Not enough funds to transfer");
        if (_accountToApprovals[_from][_to] < _value)
            return false;
        // in the past we needed to worry about buffer overflows
        // but since Solidity 0.8.0 we do not        
        _accountToBalance[_from] -= _value;
        _accountToBalance[_to] += _value;
        emit Transfer(_from, _to, _value);
        _accountToApprovals[_from][_to] -= _value;
        return true;
    }
// ...

And the passing tests:

# ...
  Contract: VCoin
    tests with no accounts
      ✓ should return right value for name() (175ms)
      ✓ should return right value for symbol() (125ms)
      ✓ should return right value for decimals() (51ms)
      ✓ should return right value for totalSupply() (130ms)
    tests with one account
      ✓ should return right value for accounts[0] at start (247ms)
      ✓ should return 0 if account doesn't exist (85ms)
    tests with two accounts
      ✓ should allow an account to approve if it has funds (488ms)
      ✓ should return false on approve if account doesn't have funds (193ms)
      ✓ should allow for approve to be called twice and overwritten (712ms)
      ✓ should return the full amount approved if an account asks for this right after appro
ve (457ms)                                                                                 
      ✓ should return 0 for allowance if no approve was called (88ms)
      ✓ should throw if amount is larger than the user has (3561ms)`transfer()` should work with or without approval (446ms)
      ✓ a `transfer` of zero is treated normally with Transfer event (203ms)
      ✓ should not transfer if approve is not called first for `transferFrom()` (231ms)
      ✓ should successfully transfer if approve is called first & have correct allowance() f
or `transferFrom()` (1351ms)                                                               
      ✓ should emit Transfer event if approve is called first for `transferFrom()` (686ms)


  17 passing (11s)

We have a fully operating ERC-20 compliant coin! This could be deployed if you were eager.

Refactoring

Notice that transfer and transferFrom look awfully similar. Now that we have a fully working test suite we can start to refactor our code with confidence: so long as we pass the tests, we can make the changes to readability or compactness that we desire.

// ... excised
    function transfer(address _to, uint256 _value)
        public
        override
        returns (bool success) {
        return transferFrom(msg.sender, _to, _value);
    }

    function transferFrom(address _from, address _to, uint256 _value)
        public
        override
        returns (bool success) {
        require(_accountToBalance[_from] >= _value, "Not enough funds to transfer");
        if(msg.sender != _from || _accountToApprovals[_from][_to] < _value)
            return false;
        // in the past we needed to worry about buffer overflows
        // but since Solidity 0.8.0 we do not        
        _accountToBalance[_from] -= _value;
        _accountToBalance[_to] += _value;
        emit Transfer(_from, _to, _value);
        _accountToApprovals[_from][_to] -= _value;
        return true;
    }
// ... 

What's interesting with how Ethereum operates is that space is a more important consideration than speed (most of the time). That means instead of storing arrays on the blockchain, we're better off reconstructing the array from scratch on each call. This will lower the gas cost to call any non-view function.

Notes for Improvement

This was a first pass I did about a month back. It's worth noting as there are some points that could be improved.

For one, the spec doesn't mention whether approved balances should decline over time (e.g. if you approve for 10 and transfer 2, you should be approved to approve another 8) or reset each time to zero after a successful transfer or whether to ignore this entirely and let the client handle it. We should state an assumption and test for it.

Transfer events on the "genesis" issuance of token to the contract owner / deployer is also not tested for. This is called for in the spec but it was missed on first write.

Also worth noting you'll almost never write an ERC20 or ERC721 in practice. Instead you'll rely on something like OpenZeppelin which has one for you that you can inherit for modifying (via contract myCoin is ERC20).

Final Thoughts

Though its incredibly laborious to type this way, its worth it. You get to triple check your every assumption at every step, which greatly improves catching all the little bugs. Cryptocurrencies and DeFi rely on immutable code so it's tripley important to catch everything upfront before you have millions locked up in some smart contract. Testing-before-writing won't guarantee you'll catch everything but it does ensure you'll state all your assumptions in english and then actualize it into code, cutting down on "gotchas" and other mistakes.

Full Code

const VCoin = artifacts.require("VCoin"); // this is auto-deployed in the console
const { assert, expect, should } = require("chai");
const truffleAssert = require("truffle-assertions");

contract("VCoin", async (accounts) => {
  describe("tests with no accounts", () => {
    beforeEach(async () => {
      this.token = await VCoin.deployed();
      this.baseAct = accounts[0];
      this.otherAct = accounts[1];
    });

    it("should return right value for name()", async () => {
      const { token } = this;
      const name = await token.name.call();
      assert.equal(name, "VCoin", "name() should return VCoin");
    });

    it("should return right value for symbol()", async () => {
      const { token } = this;
      const symbol = await token.symbol.call();
      assert.equal(symbol, "VC", "symbol() should return VC");
    });

    it("should return right value for decimals()", async () => {
      const { token } = this;
      const decimals = await token.decimals.call();
      assert.equal(decimals, 8, "decimals() should return 8");
    });

    it("should return right value for totalSupply()", async () => {
      const { token } = this;
      const totalSupply = await token.totalSupply.call();
      assert.equal(totalSupply, 1000000, "totalSupply() should return 1000000");
    });
  });

  describe("tests with one account", () => {
    beforeEach(async () => {
      this.token = await VCoin.deployed();
      this.baseAct = accounts[0];
      this.otherAct = accounts[3];
    });

    it("should return right value for accounts[0] at start", async () => {
      const { token, baseAct } = this;
      let baseActBalance = await token.balanceOf.call(baseAct);
      let totalSupply = await token.totalSupply.call();

      // get out the actual values
      baseActBalance = baseActBalance.words[baseActBalance.length - 1];
      totalSupply = totalSupply.words[totalSupply.length - 1];

      assert.equal(
        baseActBalance,
        totalSupply,
        `accounts[0] should have ${totalSupply} at start but has ${baseActBalance}`
      );
    });

    it("should return 0 if account doesn't exist", async () => {
      const { token, otherAct } = this;
      const otherActBalance = await token.balanceOf.call(otherAct);
      assert.equal(
        otherActBalance,
        0,
        "accounts[1] should have balance of zero at start"
      );
    });
  });

  describe("tests with two accounts", () => {
    beforeEach(async () => {
      this.token = await VCoin.deployed();
      this.baseAct = accounts[0];
      this.otherAct = accounts[5];
    });

    it("should allow an account to approve if it has funds", async () => {
      const { token, baseAct, otherAct } = this;
      const approvalAmt = 1;

      const success = await token.approve.call(otherAct, approvalAmt);
      assert.equal(
        success,
        true,
        "baseAct should approve transfer to otherAct"
      );
      let tx = await token.approve.sendTransaction(otherAct, approvalAmt);
      truffleAssert.eventEmitted(tx, "Approval", (ev) => {
        return (
          ev._owner == baseAct &&
          ev._spender == otherAct &&
          ev._value == approvalAmt
        );
      });
    });

    it("should return false on approve if account doesn't have funds", async () => {
      const { token, baseAct, otherAct } = this;
      const baseActBalance = await token.balanceOf.call(baseAct);
      const approvalAmt = baseActBalance + 1;

      const success = await token.approve.call(otherAct, approvalAmt);
      assert.equal(
        success,
        false,
        "baseAct should not be able to transfer more than it has"
      );
      let tx = await token.approve.sendTransaction(otherAct, approvalAmt);
      truffleAssert.eventNotEmitted(tx, "Approval");
    });

    it("should allow for approve to be called twice and overwritten", async () => {
      const { token, baseAct, otherAct } = this;
      const baseActBalance = await token.balanceOf.call(baseAct);
      const approvalAmt = 15;
      const secondApprovalAmt = 24;

      let success = await token.approve.call(otherAct, approvalAmt);
      assert.equal(success, true);
      let tx = await token.approve.sendTransaction(otherAct, approvalAmt);
      truffleAssert.eventEmitted(tx, "Approval");
      let allowance = await token.allowance.call(baseAct, otherAct);
      assert.equal(
        allowance,
        approvalAmt,
        `incorrect allowance returned: ${allowance} and ${approvalAmt}`
      );

      success = await token.approve.call(otherAct, secondApprovalAmt);
      assert.equal(success, true);
      tx = await token.approve.sendTransaction(otherAct, secondApprovalAmt);
      truffleAssert.eventEmitted(tx, "Approval");
      allowance = await token.allowance.call(baseAct, otherAct);
      assert.equal(
        allowance,
        secondApprovalAmt,
        `incorrect allowance returned: ${allowance} and ${secondApprovalAmt}`
      );
    });

    it("should return the full amount approved if an account asks for this right after approve", async () => {
      const { token, baseAct, otherAct } = this;
      const baseActBalance = await token.balanceOf.call(baseAct);
      const approvalAmt = 11;

      const success = await token.approve.call(otherAct, approvalAmt);
      assert.equal(success, true);
      let tx = await token.approve.sendTransaction(otherAct, approvalAmt);
      truffleAssert.eventEmitted(tx, "Approval");

      const allowance = await token.allowance.call(baseAct, otherAct);
      assert.equal(allowance, approvalAmt, "incorrect allowance returned");
    });

    it("should return 0 for allowance if no approve was called", async () => {
      const { token, baseAct } = this;

      let allowed = await token.allowance.call(baseAct, accounts[3]);
      assert.equal(
        allowed,
        0,
        "there should be zero allowance when no approve was called"
      );
    });

    it("should throw if amount is larger than the user has", async () => {
      const { token, baseAct, otherAct } = this;
      const transferAmt = (await token.balanceOf.call(baseAct)) + 1;
      await truffleAssert.fails(token.transfer(otherAct, transferAmt));
    });

    it("`transfer()` should work with or without approval", async () => {
      const { token, baseAct, otherAct } = this;
      const baseActBalance = await token.balanceOf.call(baseAct);
      const otherActBalance = await token.balanceOf.call(otherAct);
      const transferAmt = 10;

      let success = await token.transfer.call(otherAct, transferAmt);
      let tx = await token.transfer.sendTransaction(otherAct, transferAmt);
      assert.equal(
        success,
        true,
        "transfer should successfully return with or without approval"
      );
      truffleAssert.eventEmitted(tx, "Transfer", (ev) => {
        return (
          ev._from == baseAct && ev._to == otherAct && ev._value == transferAmt
        );
      });

      const baseActBalanceAfter = await token.balanceOf.call(baseAct);
      const otherActBalanceAfter = await token.balanceOf.call(otherAct);

      assert.equal(
        baseActBalance - transferAmt,
        baseActBalanceAfter,
        "base account balance is incorrect after transfer"
      );
      assert.equal(
        Number(otherActBalance) + transferAmt,
        otherActBalanceAfter,
        "other account balance is incorrect after transfer"
      );
    });

    it("a `transfer` of zero is treated normally with Transfer event", async () => {
      const { token, baseAct, otherAct } = this;
      const transferAmt = 0;
      let tx = await token.transfer.sendTransaction(otherAct, transferAmt);
      truffleAssert.eventEmitted(tx, "Transfer", (ev) => {
        return (
          ev._from == baseAct && ev._to == otherAct && ev._value == transferAmt
        );
      });
    });

    it("should not transfer if approve is not called first for `transferFrom()`", async () => {
      const { token, baseAct, otherAct } = this;
      const transferAmt = 10;

      let success = await token.transferFrom.call(
        baseAct,
        otherAct,
        transferAmt
      );
      assert.equal(
        success,
        false,
        "should not be able to transfer if we have not approved yet"
      );
      let tx = await token.transferFrom.sendTransaction(
        baseAct,
        otherAct,
        transferAmt
      );
      truffleAssert.eventNotEmitted(tx, "Transfer");
    });

    it("should successfully transfer if approve is called first & have correct allowance() for `transferFrom()`", async () => {
      const { token, baseAct, otherAct } = this;
      const baseActBalance = await token.balanceOf.call(baseAct);
      const otherActBalance = await token.balanceOf.call(otherAct);
      const approvalAmt = 15;
      const transferAmt = 10;

      let approval = await token.approve.call(otherAct, approvalAmt);
      assert.equal(approval, true);
      let tx = await token.approve.sendTransaction(otherAct, approvalAmt);
      truffleAssert.eventEmitted(tx, "Approval");

      let success = await token.transferFrom.call(
        baseAct,
        otherAct,
        transferAmt
      );
      assert.equal(
        success,
        true,
        "transfer should successfully return after approval"
      );
      tx = await token.transferFrom.sendTransaction(
        baseAct,
        otherAct,
        transferAmt
      );
      truffleAssert.eventEmitted(tx, "Transfer", (ev) => {
        return (
          ev._from == baseAct && ev._to == otherAct && ev._value == transferAmt
        );
      });

      const baseActBalanceAfter = await token.balanceOf.call(baseAct);
      const otherActBalanceAfter = await token.balanceOf.call(otherAct);

      assert.equal(
        baseActBalance - transferAmt,
        baseActBalanceAfter,
        "base account balance should is incorrect after transfer"
      );
      assert.equal(
        Number(otherActBalance) + transferAmt,
        otherActBalanceAfter,
        "other account balance should is incorrect after transfer"
      );

      const remainingAllowance = await token.allowance.call(baseAct, otherAct);
      assert.equal(
        approvalAmt - transferAmt,
        remainingAllowance,
        "remaining allowance is not correct"
      );
    });

    it("should emit Transfer event if approve is called first for `transferFrom()`", async () => {
      const { token, baseAct, otherAct } = this;
      const approvalAmt = 15;
      const transferAmt = 10;

      let approval = await token.approve.call(otherAct, approvalAmt);
      assert.equal(approval, true);
      let tx = await token.approve.sendTransaction(otherAct, approvalAmt);
      truffleAssert.eventEmitted(tx, "Approval");

      let success = await token.transferFrom.call(
        baseAct,
        otherAct,
        transferAmt
      );
      assert.equal(success, true);
      tx = await token.transferFrom.sendTransaction(
        baseAct,
        otherAct,
        transferAmt
      );
      truffleAssert.eventEmitted(tx, "Transfer", (ev) => {
        return (
          ev._from == baseAct && ev._to == otherAct && ev._value == transferAmt
        );
      });
    });
  });
});
pragma solidity =0.8.0;

import "./interfaces/IERC20.sol";

contract VCoin is IERC20 {

    // are events automatically declared?

    string private _name = "VCoin";
    string private _symbol = "VC";
    uint8 private _decimals = 8;  // default 'uint' is 'uint256'
    uint256 private _totalSupply = 1000000; // million

    mapping(address => uint256) private _accountToBalance;
    mapping(address => mapping(address => uint256)) private _accountToApprovals;

    constructor() {
        _accountToBalance[msg.sender] = _totalSupply;
        // should take the name, symbol, initial supply, firstMan values
        // can then be crated with `VCoin.new(name, symbol, supply, firstMan)`
    }

    function name() public override view returns (string memory) {
        return _name;
    }
    function symbol() public override view returns (string memory) {
        return _symbol;
    }
    function decimals() public override view returns (uint8) {
        return _decimals;
    }
    function totalSupply() public override view returns (uint256) {
        return _totalSupply;
    }


    function balanceOf(address _owner) public override view returns (uint256 balance) {
        return _accountToBalance[_owner];
    }

    function approve(address _spender, uint256 _value)
        public
        override
        returns (bool success) {
        if (_accountToBalance[msg.sender] < _value)
            return false;
        _accountToApprovals[msg.sender][_spender] = _value;
        emit Approval(msg.sender, _spender, _accountToApprovals[msg.sender][_spender]);
        return true;
    }

    function allowance(address _owner, address _spender)
        public
        override
        view
        returns (uint256 remaining) {
        return _accountToApprovals[_owner][_spender];
    }

    function transfer(address _to, uint256 _value)
        public
        override
        returns (bool success) {
        return transferFrom(msg.sender, _to, _value);
    }

    function transferFrom(address _from, address _to, uint256 _value)
        public
        override
        returns (bool success) {
        require(_accountToBalance[_from] >= _value, "Not enough funds to transfer");
        if(msg.sender != _from || _accountToApprovals[_from][_to] < _value)
            return false;
        // in the past we needed to worry about buffer overflows
        // but since Solidity 0.8.0 we do not        
        _accountToBalance[_from] -= _value;
        _accountToBalance[_to] += _value;
        emit Transfer(_from, _to, _value);
        _accountToApprovals[_from][_to] -= _value;
        return true;
    }
}

Footnotes

[1]: A sidebar on writing tests: the "official" way to write tests is to write unit tests that test exactly one thing and look for one input. That's how Kent Beck, sort of the godfather of unit testing, described it. Later on, people invented other ways to write tests. I'm partial to Behavior Driven Testing as this gets closer to how your end user, the person who ultimately matters, will use the product. Because our end users are expecting certain functions to exist and work in specific ways, we'll be writing unit tests. In practice, you'll almost never adhere strictly to one or the other but it helps to stay spiritually in their direction. Testing is a holy war full of highly paid consultants the likes of Vi-Emacs has never seen.

Share On:
EmailTwitterHackerNewsRedditLinkedIn