I have been brushing up my chops with Solidity lately, so I decided to sit down and write a simple token vault implementation. It’s going to be a simple holding contract for balances that we can extend with other functionality in the future, like bridging, merkle trees, or decentralized swaps if we really wanted to get wild with it.

The code for this can be found at dylanlott/manacrypt.

Prerequisities

I’m going to use Forge to develop this since it’s the latest and greatest (and it really is the greatest in Solidity development). You’ll need to install it before you can run tests or anvil instances with it.

So let’s get to it!

Lay out the groundwork

You have two options to start. You can use forge init to setup a new Forge project, or you can start with the a boilerplate, like the SolidityLabs ERC20 boilerplate.

I chose to do it both ways, first I did it entirely manually with forge init and then I tried it with the ERC20 template linked above. The template does make it much easier, but doing it manually will help you understand how everything is working together. If you do it manually you’ll have to install the necessary dependencies and edit your remappings.txt file.

For example, if you want to use OpenZeppelin presets, which I would highly recommend, you’ll need to run forge install OpenZeppelin/openzeppelin-contracts to install them into forge. If that works you should see the following output:

❯ forge install OpenZeppelin/openzeppelin-contracts

Installing openzeppelin-contracts in "/Users/shakezula/dev/mtg-eth/lib/openzeppelin-contracts" (url: Some("https://github.com/OpenZeppelin/openzeppelin-contracts"), tag: None)

    Installed openzeppelin-contracts

And your remappings.txt file will need to look like:

forge-std/=lib/forge-std/src/
openzeppelin-contracts/=lib/openzeppelin-contracts/contracts/

If you have your base contract dependencies setup, we can dive into the contracts themselves. If you chose to use the ERC20 boilerplate template, you can jump straight to this point.

Contracts

We will make two main contracts to start with: a new ERC20 token called ManaToken that is a ERC20PresetMinterPauser, and a ManaCrypt that will act as a simple contract bank for now, allowing deposits and withdrawals.

The idea is that, when we’re finished with this series, we should have a ManaCrypt contract that accepts Ethereum deposits and exchanges them into ManaToken balances for users. Those ManaTokens can then be used in our fictional ecosystem to carry out all sorts of tasks.

I chose the ERC20PresetMinterPauser because we can pause and mint with it by default, two functions we’ll find handy later.

ManaToken ERC20 contract

Let’s look at the code for the ERC20 contract.

pragma solidity >=0.8.0;

import { ERC20PresetMinterPauser } from "openzeppelin-contracts/token/ERC20/presets/ERC20PresetMinterPauser.sol";


contract ManaToken is ERC20PresetMinterPauser {
    constructor() ERC20PresetMinterPauser("ManaToken", "MANA") {
        this;
    }
}

It’s actually dead simple. This is all we have to do to create our ManaToken.

This inherits all of the ERC20 functions, as well as the minting and pausing functionality from the OpenZeppelin contract with minimal effort. Our constructor function simply declares our contract’s token name and symbol, extending from the ERC20PresetMinterPauser.

ManaCrypt contract

Here’s the contract for the ManaCrypt. It’s more involved than the token, but it’s surprisingly straightforward.

// SPDX-License-Identifier: MIT
pragma solidity 0.8.13;

contract ManaCrypt {
    // balances maps address to account balances
    mapping (address => uint256) private balances;
    // the owner of the bank contract.
    address public owner;
    // constructor sets the owner to the msg.sender
    constructor() {
        owner = msg.sender;
    }

    // deposit a non-zero amount into the bank. it returns the 
    // user's balance in the bank after the deposit.
    function deposit() public payable returns (uint256) {
        // increment balance by amount sent to contract
        require(msg.value != 0);
        balances[msg.sender] += msg.value;
        return balances[msg.sender];
    }

    // balance returns the sender's balance in the ManaCrypt 
    function balance() public view returns (uint256) {
        return balances[msg.sender];
    }

    // withdraw checks that the amount is a valid withdrawal amount 
    // and then transfers it to the account and returns the sender's 
    // remaining balance in the bank.
    function withdraw(uint256 amount) public returns (uint256) {
        require(amount <= balances[msg.sender], "insufficient funds");
        balances[msg.sender] -= amount;
        payable(msg.sender).transfer(amount);
        return balances[msg.sender];
    }
}

We setup a mapping of addresses to balances, declare a contract owner, and set the owner to whoever creates the contract in the constructor function.

We have a function for adding money into the account - deposit, a function for getting money out - withdraw, and a function for checking the amount you have in your account - balance .

Note that the balance function is a view: This means it cannot alter state in any way, a nice compile-time constraint we can add to ensure we minimize the number of functions that change state. We should keep state modifications to a minimum so as to keep the surface area for bugs minimal.

Our withdraw function has a require on it, which lets us set a strict requirement that the amount to withdraw is less than or equal to the balance of the sender. If this isn’t met, the function immediately errors, preventing us from messing up before we’ve even really started.

Also of note is that deposit is a payable, which lets the function accept payments. We’ll take a closer look at what payable means for our deposit function in the next section during testing.

Testing

One of the biggest benefits of Forge is that it lets you write tests in Solidity, which is amazing, because prior to Forge, for example with Hardhat or Truffle, you were writing tests in JavaScript. That wasn’t necessarily the worst (depending on who you ask), but it was a context switch. Now, with Forge, you can write them all in Solidity, and it makes things much easier to stay in context. For me, it’s a huge boost to development.

First, Let’s setup a simple test skeleton.

pragma solidity >=0.8.0;

import {console} from "forge-std/console.sol";
import {stdStorage, StdStorage, Test} from "forge-std/Test.sol";

import {Utils} from "./utils/Utils.sol";
import { ManaCrypt } from "../ManaCrypt.sol";

contract ManaCryptSetup is ManaCrypt, Test {
    Utils internal utils;
    address payable[] internal users;
    
    address internal alice;
    address internal bob;

    // setup will make 2 test users with 100 ether
    function setUp() public virtual {
        utils = new Utils();
        users = utils.createUsers(2);
        alice = users[0];
        vm.label(alice, "Alice");
        bob = users[1];
        vm.label(bob, "Bob");
    }
}

In Forge, tests are defined as contract types in Solidity. You declare a set of contracts that Forge can interpret and call as tests.

Forge also will call the setUp function before every test, similar to a beforeEach function in other testing frameworks. This lets you reliably and repeatedly specific test environments.

This initial setUp function sets up our test utils, creates a set of payable user addresses called users, creates two users with balances of 100 ethere, and then adds them to that set of users. The vm.label function tells Forge to label the address in call traces, which is useful for debugging and testing purposes.

We can check that everything is working as intended by running forge test -vvv which will run our tests with level 3 verbosity (with level 5 being the most verbose test output).

Note: These examples do use some of the test utilities from the Forge ERC20 boilerplate, since they’re quite useful and they were better than the tests I wrote the first time I set everything up manually.

Deposits

We’re going to use the same form of tests from the ERC20 boilerplate mentioned before and setup a series of setUp functions that inherit from our base ManaCrypt contract. Then we can use the setUp function like this.

contract WhenDepositingTokens is ManaCryptSetup {
    function setUp() public virtual override {
        ManaCryptSetup.setUp();
        console.log("When depositing tokens into ManaCrypt");
    }

    function transferTokenToBank(
        address from,
        uint256 transferAmount
    ) public returns (uint256) {
        vm.prank(from);
        return this.deposit{value: transferAmount}();
    }

    function testDepositOne() public {
        uint256 amount  = transferTokenToBank(alice, 1);
        assertEq(amount, 1, "failed to deposit correct amount");
        vm.prank(alice);
        assertEq(this.balance(), 1);
    }

    function testDepositHalf() public {
        uint256 amount  = transferTokenToBank(alice, 1 ether/2);
        assertEq(amount, 1 ether/2, "failed to deposit correct amount");
        vm.prank(alice);
        assertEq(this.balance(), 1 ether/2, "failed to deposit correct amount");
    }
}

We use another contract for the subset of deposit tests here and inherit from ManaCryptSetup. Then, wen can define a setUp function for these set of tests that calls the original ManaCryptSetup function, as well as any of our own custom setup logic for this set of deposit tests. This gives us a nice, extensible, but organized set of functions we can use to write tests.

We define a transferTokenToBank function that we can mock with the vm.prank function, which tells Forge to use the address provided as the caller for only the next function, in this case, the deposit invocation. Here, we can call deposit and pass it a value of the transferAmount. Now we have a highly reusable testing function for invoking the deposit function.

Then we can start declaring some test cases prefixed with test. We have a simple happy path test called testDepositOne and then a more detailed case for depositing half of a given balance.

Withdrawal

In the WhenDepositingTokens contract setUp function, we don’t need to do anything different from our ManaCryptSetup, but that isn’t the case for our WhenWithdrawingTokens contract.

Let’s look at our initial setup function for our WhenWithdrawingTokens contract.

contract WhenWithdrawingTokens is ManaCryptSetup {
    function setUp() public virtual override {
        ManaCryptSetup.setUp();
        vm.prank(alice);
        this.deposit{value: 50}();
        vm.prank(bob);
        this.deposit{value: 25}();
        console.log("When withdrawing tokens from ManaCrypt");
    }

First, we call the ManaCryptSetup setUp function, so that we have users alice and bob to work with, but then we use vm.prank to call deposit function as both alice and bob so that we have balances to withdraw.

    function withdrawFromBank(
        address withdrawer,
        uint256 amount
    ) public returns (uint256) {
        vm.prank(withdrawer);
        return this.withdraw(amount);
    }

Then, we setup a function similar to transferTokenToBank called withdrawFromBank that will prank the vm with the withdrawer’s address, and then returns the pranked call to withdraw with our amount passed to it.

In the same way as the deposit function, we can use the withdrawFromBank function to test a variety of cases. Our first happy path test is pretty straight forward now.

    function testWithdrawHalf() public {
        vm.prank(alice);
        uint256 full = this.balance();
        uint256 remaining = withdrawFromBank(alice, full / 2);
        assertEq(remaining, 25);
    }

Fuzz Testing

This is a sufficient set of tests for now, although eventually I would want to create some fuzz tests which, you guessed it, Forge also supports with assume. This is a really big deal for security-critical applications. The ability to bake fuzz-testing into development pipelines is a huge benefit to code coverage and regression testing.

Conclusion

This skeleton is a great base to start development. If you use the ERC20 templates, they even come preloaded with Github Action workflows for linting and testing, as well as testnet and mainnet deployment scripts. So when it comes time to deploy, you can easily test and then deploy to mainnet contracts with one command.

Additionally, Forge comes with other tools, like anvil, a tool that runs an EVM blockchain locally for testing and development, similar to ganache. You can connect to this EVM with MetaMask (or other wallets) for even more in-depth testing and experimentation. It has some really cool features, like letting you declare specific blocks to fork from, or letting you fork from url’s to fetch a specific state instead of starting from a local blank state. This is super powerful when you’re working with highly inter-connected or heavily dependent sets of contracts.

And that’s all for now! Happy hacking!