End-to-end guide to creating an ERC-20 token-specific DApp

View on GitHub

In this post I'll be developing a decentralised application to operate with a mintable ERC-20 token.

The purpose is then to understand the end-to-end process of writing a smart contract in Solidity and developing a specific front end to interact with this contract.

Note: this application is not production ready code. Its implementation is solely for learning purposes.

In this article

Setup

In this guide npm 7 workspaces is used to create a monorepo in order to simplify our development environment.

  1. Download and install Node.js and npm.

  2. Create a new directory and navigate to it. Also, create a new Node project with npm init.

    $ mkdir mintable-erc-20-dapp
    $ cd mintable-erc-20-dapp
    $ npm init
  3. Edit the package.json:

    package.json
    {
      "name": "mintable-erc-20-dapp",
      "version": "1.0.0",
      "description": "",
    - "main": "index.js",
    +	"workspaces": [
    +		"hardhat-env",
    +		"react-app"
    +	],
      "scripts": {
        "test": "echo \"Error: no test specified\" && exit 1"
      },
      "keywords": [],
      "author": "",
      "license": "ISC"
    }
  4. Create the workspaces folders:

    $ npm init -w hardhat-env
    $ npm init -w react-app
  5. Install the Hardhat environment dependencies:

    $ npm install --save-dev -w hardhat-env @nomiclabs/hardhat-ethers
    $ @nomiclabs/hardhat-waffle @typechain/ethers-v5 @typechain/hardhat
    $ @types/chai @types/mocha @types/node chai ethereum-waffle
    $ ethers hardhat ts-node typechain typescript
  6. Now install the react-app dependencies:

    $ npm install -w react-app @chakra-ui/react @emotion/react @emotion/styled
    $ @types/node @types/react @types/react-dom ethers formik react react-dom
    $ react-scripts typescript web3modal
  7. Add some scripts that will be handy:

    hardhat-env/package.json
    "scripts": {
    - "test": "echo \"Error: no test specified\" && exit 1"
    + "test": "hardhat test",
    + "node": "hardhat node",
    + "compile": "hardhat compile",
    + "share": "hardhat run scripts/shareFiles.ts",
    + "deploy": "hardhat run --network localhost scripts/deploy.ts",
    + "mint": "hardhat mint --network localhost",
    },
    react-app/package.json
    "scripts": {
    - "test": "echo \"Error: no test specified\" && exit 1"
    + "start": "react-scripts start",
    + "build": "react-scripts build",
    + "eject": "react-scripts eject",
    + "type-check": "tsc --pretty --noEmit",
    },
    package.json
    "scripts": {
    -	"test": "echo \"Error: no test specified\" && exit 1"
    + "node": "npm run node --workspace=hardhat-env",
    + "test-hardhat": "npm run test --workspace=hardhat-env",
    + "compile-share-deploy": "npm run compile --workspace=hardhat-env && npm run share --workspace=hardhat-env && npm run deploy --workspace=hardhat-env",
    + "mint": "npm run mint --workspace=hardhat-env",
    + "start-app": "npm start --workspace=react-app",
    }
  8. Lastly, create a hardhat configuration file and import the plugins.

    In case we use Metamask we'll need to either assign in hardhat.config.ts 1337 to the chainId of our local network, or set the chainId of localhost in Metamask to 31337:

    hardhat-env/hardhat.config.ts
    import { HardhatUserConfig } from 'hardhat/types';
    import '@typechain/hardhat';
    import '@nomiclabs/hardhat-ethers';
    import '@nomiclabs/hardhat-waffle';
     
    const config: HardhatUserConfig = {
      solidity: '0.8.4',
      networks: {
        hardhat: {
          chainId: 1337, // https://hardhat.org/metamask-issue.html
        },
      },
    };

Writing the contract

The smart contract is written in Solidity and complies with the EIP-20 standard specifications.

hardhat-env/contracts/MintableERC20.sol
// SPDX-License-Identifier: Apache-2.0
pragma solidity ^0.8.4;
 
contract MintableERC20 {
    string public name;
    string public symbol;
    uint8 public decimals;
    address public minter;
    uint256 public totalSupply;
    mapping(address => uint256) private _balances;
    mapping(address => mapping(address => uint256)) private _allowances;
 
    constructor(string memory name_, string memory symbol_) {
        name = name_;
        symbol = symbol_;
        decimals = 18;
        minter = msg.sender;
    }
 
    error InsufficientBalance(uint256 requested, uint256 available);
    event Transfer(address indexed sender, address indexed receiver, uint256 amount);
    event Approval(address indexed owner, address indexed spender, uint256 amount);
 
    function balanceOf(address account) public view returns (uint256) {
        return _balances[account];
    }
 
    function mint(address receiver, uint256 amount) public {
        require(msg.sender == minter, 'Only the owner is authorized to mint tokens');
        totalSupply += amount;
        _balances[receiver] += amount;
        emit Transfer(address(0), receiver, amount);
    }
 
    function approve(address spender, uint256 amount) public returns (bool) {
        _approve(msg.sender, spender, amount);
        return true;
    }
 
    function _approve(
        address owner,
        address spender,
        uint256 amount
    ) public returns (bool) {
        require(owner != address(0), 'Transfer from zero address is not permitted');
        require(spender != address(0), 'Transfer to zero address is not permitted');
        _allowances[owner][spender] = amount;
        emit Approval(owner, spender, amount);
        return true;
    }
 
    function allowance(address owner, address spender) public view returns (uint256) {
        return _allowances[owner][spender];
    }
 
    function transfer(address receiver, uint256 amount) public returns (bool) {
        _transfer(msg.sender, receiver, amount);
        return true;
    }
 
    function transferFrom(
        address sender,
        address receiver,
        uint256 amount
    ) public returns (bool) {
        _transfer(sender, receiver, amount);
        uint256 _allowance = _allowances[sender][msg.sender];
        require(_allowance >= amount, 'There is no allowance to transfer this amount');
        _approve(sender, msg.sender, _allowance - amount);
        return true;
    }
 
    function _transfer(
        address sender,
        address receiver,
        uint256 amount
    ) internal {
        require(sender != address(0), 'Transfer from zero address is not permitted');
        require(receiver != address(0), 'Transfer to zero address is not permitted');
        if (amount > balanceOf(sender))
            revert InsufficientBalance({ requested: amount, available: balanceOf(msg.sender) });
        _balances[sender] -= amount;
        _balances[receiver] += amount;
        emit Transfer(sender, receiver, amount);
    }
}

When the contract gets deployed, its state variables will be initialised, two of which are passed as arguments to the constructor function, namely name and symbol.

The logic of the contract is fairly simple.

Only the deployer address will be permitted to mint tokens.

In order to transfer tokens, it is required that the sender address does possess at least the same amount of tokens it intends to transfer.

In case that an address requests for another address to transfer an amount of tokens, it will need to be previously allowed, i.e., before transferFrom is called, the spender address will have to be approved so that it can get the requested amount of tokens from the owner.

The transactions as well as the approvals will emit an Event. Later, I will use the transaction event to keep track of the balance of the current account in the decentralised app.

Testing the contract

These operations may be better understood by testing the contract.

In this guide a hardhat plugin for integration with Waffle and another for Ethers.js are used to write the required tests with Mocha alongside Chai.

This tests are structured in three main describe calls:

hardhat-env/test/mintableerc20.spec.ts
describe('TFT', function () {
  let MintableERC20Factory: ContractFactory,
    tft: Contract,
    owner: SignerWithAddress,
    acc2: SignerWithAddress,
    acc3: SignerWithAddress;
  before('Get Factory and Signers', async () => {
    MintableERC20Factory = await ethers.getContractFactory('MintableERC20');
    [owner, acc2, acc3] = await ethers.getSigners();
  });
  beforeEach('Deploy factory', async () => {
    tft = await MintableERC20Factory.deploy('Test Fungible Token', 'TFT');
    await tft.deployed();
  });
  describe('Error handling', () => {
    it('Shoud revert unouthorized minter', async () => {
      const amount = ethers.utils.parseUnits('1000', 'ether');
      await expect(tft.connect(acc2).mint(acc3.address, amount)).to.be.revertedWith(
        'Only the owner is authorized to mint tokens'
      );
    });
    it('Shoud revert invalid transfer due to lack of funds', async () => {
      const amount = ethers.utils.parseUnits('1000', 'ether');
      await expect(tft.connect(acc2).transfer(acc3.address, amount)).to.be.revertedWith(
        `InsufficientBalance(${amount}, 0)`
      );
    });
    it('Shoud revert invalid transfer due to no allowance', async () => {
      const amount = ethers.utils.parseUnits('1000', 'ether');
      await tft.mint(acc2.address, amount);
      await expect(tft.connect(acc3).transferFrom(acc2.address, acc3.address, amount)).to.be.revertedWith(
        'There is no allowance to transfer this amount'
      );
    });
  });
  describe('Minting', () => {
    it('Should mint tokens correctly', async () => {
      const amount = ethers.utils.parseUnits('1000', 'ether');
      await tft.mint(acc2.address, amount);
      const acc2Balance = await tft.balanceOf(acc2.address);
      expect(acc2Balance).to.equal(amount);
    });
  });
  describe('Transactions', () => {
    it('Should transfer tokens correctly', async () => {
      const amount = ethers.utils.parseUnits('1000', 'ether');
      const initialAcc2Balance = await tft.balanceOf(acc2.address);
      const initialAcc3Balance = await tft.balanceOf(acc3.address);
      await tft.mint(acc2.address, amount);
      await tft.connect(acc2).transfer(acc3.address, amount);
      const acc2Balance = await tft.balanceOf(acc2.address);
      const acc3Balance = await tft.balanceOf(acc3.address);
      expect(acc2Balance).to.equal(initialAcc2Balance);
      expect(acc3Balance).to.equal(initialAcc3Balance.add(amount));
    });
 
    it('Should transfer tokens from correctly', async () => {
      const amount = ethers.utils.parseUnits('1000', 'ether');
      const initialAcc2Balance = await tft.balanceOf(acc2.address);
      const initialAcc3Balance = await tft.balanceOf(acc3.address);
      await tft.mint(acc2.address, amount);
      await tft.connect(acc2).approve(acc3.address, amount);
      await tft.connect(acc3).transferFrom(acc2.address, acc3.address, amount);
      const acc2Balance = await tft.balanceOf(acc2.address);
      const acc3Balance = await tft.balanceOf(acc3.address);
      expect(acc2Balance).to.equal(initialAcc2Balance);
      expect(acc3Balance).to.equal(initialAcc3Balance.add(amount));
    });
  });
});

Now run the script test-hardhat:

$ npm run test-hardhat
 
> mintable-erc-20-dapp@1.0.0 test-hardhat
> npm run test --workspace=hardhat-env
 
 
> hardhat@1.0.0 test
> hardhat test
 
Generating typings for: 1 artifacts in dir: typechain-types for target: ethers-v5
Successfully generated 5 typings!
Compiled 1 Solidity file successfully
 
 
  TFT
    Error handling
      ✔ Shoud revert unouthorized minter (53ms)
      ✔ Shoud revert invalid transfer due to lack of funds
      ✔ Shoud revert invalid transfer due to no allowance (55ms)
    Minting
      ✔ Should mint tokens correctly
    Transactions
      ✔ Should transfer tokens correctly (94ms)
      ✔ Should transfer tokens from correctly (93ms)
 
 
  6 passing (2s)

As we can see, hardhat test will compile the solidity contract and generate typings beforehand. This will create the folders artifacts, cache, and typechain-types.

We are mostly interested in MintableERC20.json under /hardhat-env/artifacts/contracts/MintableERC20.sol, and MintableERC20.ts under /hardhat-env/typechain-types, as they contain the ABI and the typings that have been generated, respectively.

We'll use them in our React application to interact with the smart contract using Ethers.js.

In order to import the files that we need from the workspace hardhat-env to react-app, we could do so simply with import statements.

However, in case we'd like to keep a copy of those files in react-app or just to avoid naming conflicts with packages installed in our workspace and other workspaces names, we could run a simple script to read the files we need and write them in react-app to import them from there:

hardhat-env/scripts/shareFiles.ts
async function shareFiles() {
  const hardhatContractsPath = join(__dirname, '../artifacts/contracts');
  const hardhatContractsTypePath = join(__dirname, '../typechain-types');
  const reactContractsPath = join(__dirname, '../../react-app/src/utils/contracts');
  const reactContractsTypesPath = join(__dirname, '../../react-app/src/utils/types');
  const contractsList = readdirSync(hardhatContractsPath).filter((path) => /\.sol$/.test(path));
  contractsList.forEach((slug) => {
    const contractSlug = slug.replace(/\.sol/, '');
    const contractJSON = contractSlug + '.json';
    const contractJSONPath = join(reactContractsPath, contractJSON);
    const contractType = contractSlug + '.ts';
    const contractTypePath = join(reactContractsTypesPath, contractType);
    const artifact = artifacts.readArtifactSync(contractSlug);
    if (!existsSync(reactContractsPath)) {
      mkdirSync(reactContractsPath, { recursive: true });
    }
    writeFileSync(contractJSONPath, JSON.stringify(artifact, null, 2));
    const typeData = readFileSync(join(hardhatContractsTypePath, contractType), 'utf8');
    if (!existsSync(reactContractsTypesPath)) {
      mkdirSync(reactContractsTypesPath, { recursive: true });
    }
    writeFileSync(contractTypePath, typeData);
  });
  const contractTypeCommon = 'common.ts';
  const contractTypeCommonPath = join(reactContractsTypesPath, contractTypeCommon);
  const typeCommonData = readFileSync(join(hardhatContractsTypePath, contractTypeCommon), 'utf8');
  if (!existsSync(reactContractsTypesPath)) {
    mkdirSync(reactContractsTypesPath, { recursive: true });
  }
  writeFileSync(contractTypeCommonPath, typeCommonData);
  return {
    contractsNum: contractsList.length,
    reactContractsPath: reactContractsPath,
    reactContractsTypesPath: reactContractsTypesPath,
  };
}

In order to have tokens at our disposal it is only needed a Hardhat task to mint and send tokens to a specified address.

This is easily accomplished by adding a simple task under the /hardhat/tasks folder.

To simplify the process, a pre-specified amount of 100 TFT is set.

hardhat-env/tasks/mint.ts
task('mint', 'Mint TFT and internally transfer it to an address')
  .addPositionalParam('receiver', 'The address that will receive them')
  .setAction(async ({ receiver }, hre) => {
    if (hre.network.name === 'hardhat') {
      console.warn(
        'The faucet has been run to the Hardhat Network, so it got automatically created ' +
          "and destroyed. Use the Hardhat option '--network localhost'"
      );
    }
    if (receiver === undefined) {
      console.warn('A recieiver address is required');
    }
 
    const addressesFilePath = join(__dirname, '../../react-app/src/utils/contracts/contracts-addresses.json');
    if (!existsSync(addressesFilePath)) {
      console.error('You need to deploy your contract first');
      return;
    }
 
    const addressesJson = readFileSync(addressesFilePath, 'utf-8');
    const address: string = JSON.parse(addressesJson)['MintableERC20'];
    const code = await hre.ethers.provider.getCode(address);
    if (code === '0x') {
      console.error('You need to deploy your contract first');
      return;
    }
 
    const token = await hre.ethers.getContractAt('MintableERC20', address);
    const amount = '100';
    const tftAmount = hre.ethers.utils.parseUnits(amount, 18);
    const tx = await token.mint(receiver, tftAmount);
    await tx.wait();
 
    console.log(`${amount} TFTs transferred to ${receiver}`);
  });

Lastly, it is just needed to import this file in the hardhat configuration:

hardhat-env/hardhat.config.ts
import { HardhatUserConfig } from 'hardhat/types';
import '@typechain/hardhat';
import '@nomiclabs/hardhat-ethers';
import '@nomiclabs/hardhat-waffle';
+ import './tasks/mint';

Deploying the contract

In Hardhat we can deploy a contract in a localhost network. To start a local node in this app, run npm run node, and to deploy the contract npm run compile-share-deploy.

The latter command will, furthermore, ensure our React app has the current contract address, typings and ABI after compiling the solidity file and deploying it in the localhost network.

$ npm run compile-share-deploy
 
> mintable-erc-20-dapp@1.0.0 compile-share-deploy
> npm run compile --workspace=hardhat-env && npm run share --workspace=hardhat-env && npm run deploy --workspace=hardhat-env
 
 
> hardhat@1.0.0 compile
> hardhat compile
 
Nothing to compile
No need to generate any newer typings.
 
> hardhat@1.0.0 share
> hardhat run scripts/shareFiles.ts
 
No need to generate any newer typings.
  ✓ 1 compiled artifact(s) copied to /.../mintable-erc-20-dapp/react-app/src/utils/contracts
  ✓ 1 contract(s) type(s) copied to /.../mintable-erc-20-dapp/react-app/src/utils/types
  ✓ Contract type required module common.ts copied to /.../mintable-erc-20-dapp/react-app/src/utils/types
 
> hardhat@1.0.0 deploy
> hardhat run --network localhost scripts/deploy.ts
 
No need to generate any newer typings.
  ✓ MintableERC20 deployed at: 0x...
  ✓ 1 contract address(es) has(have) been copied to /.../mintable-erc-20-dapp/react-app/src/utils/contracts

Front-end

Our front end is a simple React.js application written in TypeScript that consists of three main components to show the balance, to make transactions and to check the history of incoming and outgoing transactions.

In order for our DApp to work it is needed that a crypto wallet is installed in the browser and injects the ethereum property to the window.

If either a different account or a different chain are set in our ethereum provider, the page will reload.

react-app/src/utils/hooks/index.ts
export const useAccountAndChainChange = () => {
  const handleChange = () => {
    window.location.reload();
  };
  useEffect(() => {
    if (window.ethereum) {
      window.ethereum.on('accountsChanged', handleChange);
      window.ethereum.on('chainChanged', handleChange);
    }
    return () => {
      window.ethereum.removeListener('accountsChanged', handleChange);
      window.ethereum.removeListener('chainChanged', handleChange);
    };
  }, []);
};

Once we start the react application we are requested to enable Ethereum which will fetch an account address and will create two contracts.

react-app/src/utils/helpers.ts
export const fetchData = async () => {
  const accounts = await window.ethereum.request({ method: 'eth_requestAccounts' });
 
  const provider = new ethers.providers.JsonRpcProvider();
  const contract = new ethers.Contract(addresses['MintableERC20'], mintableErc20.abi, provider);
 
  const web3Modal = new Web3Modal();
  const connection = await web3Modal.connect();
  const userProvider = new ethers.providers.Web3Provider(connection);
  const signer = userProvider.getSigner();
  const signerContract = new ethers.Contract(addresses['MintableERC20'], mintableErc20.abi, signer) as MintableERC20;
 
  return { accounts, contract, signerContract };
};

Using the Ethers.js library I create two contract objects, one to perform read-only operations and another one to perform transactions.

To create both contracts it is needed to pass a contract address, a contract interface (or ABI) and either a signer or a provider.

For the read-only contract a JsonRpcProvider is passed, and a signer for the contract that will be used to sign operations to change the state of the blockchain.

I keep the account address and both contracts in ContractContext which will provide us with them in any of the components that are descendants to ContractProvider.

Once the data in the context is defined, the components Balance, TransferFrom and Panels will be rendered.

They show the TFT (Test Fungible Token, our token) balance of the context account address in the contract, a form to transfer TFTs and a panel with incoming and outgoing transactions respectively.

I set listeners inside useEffects in custom hooks to keep all three main components up to date with the state in the blockchain (also TransferFrom, since the input data are validated using the Formik library).

In order to keep the balance synched with the state of the _balance mapping of our account in MintableERC20.sol, we use the event Transfer in our contract to create two filters:

  • filterReceived: this filter will keep all the transactions to our account (including the mint operations).
  • filterSent: this filter will keep all the transactions from our account.

We then set listeners to these events to update our application balance state.

react-app/src/utils/hooks/index.ts
export const useBalance = () => {
  const [balance, setBalance] = useState<BigNumber>(BigNumber.from(0));
  const { ctxtAccount, ctxtReadContract } = useContractContext();
  const callbackBalance = useCallback(async () => {
    if (ctxtReadContract && ctxtAccount) {
      const _balance = await ctxtReadContract.balanceOf(ctxtAccount);
      setBalance(_balance);
    }
  }, [ctxtReadContract, ctxtAccount]);
  useEffect(() => {
    const filterReceived = ctxtReadContract?.filters.Transfer(null, ctxtAccount, null);
    const filterSent = ctxtReadContract?.filters.Transfer(ctxtAccount, null, null);
    const getBalance = () => callbackBalance().catch((error) => console.log(error));
    if (filterReceived && filterSent) {
      ctxtReadContract?.on(filterReceived, getBalance);
      ctxtReadContract?.on(filterSent, getBalance);
    }
    return () => {
      if (filterReceived && filterSent) {
        ctxtReadContract?.off(filterReceived, getBalance);
        ctxtReadContract?.off(filterSent, getBalance);
      }
    };
  }, [callbackBalance, ctxtAccount, ctxtReadContract]);
  return balance;
};

A similar operation is performed in useTransactions, however, here the listeners will set the state of the transfers to an Event array.

react-app/src/utils/hooks/index.ts
export const useTransactions = () => {
  const [transfersIn, setTransfersIn] = useState<Event[] | null>(null);
  const [transfersOut, setTransfersOut] = useState<Event[] | null>(null);
  const { ctxtAccount, ctxtReadContract } = useContractContext();
  const callbackEvent = useCallback(
    async (filter: EventFilter | undefined) => {
      if (filter) {
        const _events = await ctxtReadContract?.queryFilter(filter, 0, 'latest');
        return _events ? _events : null;
      } else return null;
    },
    [ctxtReadContract]
  );
  useEffect(() => {
    const filterReceived = ctxtReadContract?.filters.Transfer(null, ctxtAccount, null);
    const filterSent = ctxtReadContract?.filters.Transfer(ctxtAccount, null, null);
    const getReceived = () =>
      callbackEvent(filterReceived)
        .then((_events) => {
          setTransfersIn(_events);
          console.log('You have received a transaction, please click on the received tab in the transaction panel.');
        })
        .catch((error) => console.error(error));
    const getSent = () =>
      callbackEvent(filterSent)
        .then((_events) => setTransfersOut(_events))
        .catch((error) => console.error(error));
    if (filterReceived && filterSent) {
      ctxtReadContract?.on(filterReceived, getReceived);
      ctxtReadContract?.on(filterSent, getSent);
    }
    return () => {
      if (filterReceived && filterSent) {
        ctxtReadContract?.off(filterReceived, getReceived);
        ctxtReadContract?.off(filterSent, getSent);
      }
    };
  }, [ctxtReadContract, ctxtAccount, callbackEvent]);
  return { transfersIn, transfersOut };
};

We map over these arrays in Panels to show the transactions.

Below we can see the mapping of our outgoing transactions. The receiver address corresponds to event.args[1] and the amount of TFT sent corresponds to event.args[2].

react-app/src/components/Panels.tsx
{transfersOut && (
  <Tbody>
    {transfersOut
      .slice(0)
      .reverse()
      .map((event) => {
        return (
          event.args && (
            <Tr key={event.transactionHash} fontSize="sm">
              <Td textAlign="center">{event.args[1]}</Td>
              <Td textAlign="right" paddingRight="2%">
                {formatAmount(event.args[2])}
              </Td>
            </Tr>
          )
        );
      })}
  </Tbody>
)}

Now that all the logic is implemented, just run npm run start-app.


Related posts

DeFi

How to build a DEXs analytics application

Introduction to obtaining and representing DEXs data using TheGraph and React.js

Traceability

Introducing Olive Oil Trust

Introduction to a series of posts about Olive Oil Trust

zk-SNARK

How to build a zero-knowledge DApp

This post offers an introduction to how to develop an application capable of generating and...

Traceability

Introducing Olive Oil Trust: front end

Next.js application that gives support to members and customers in Olive Oil Trust, and reduces...

Microservices

Approach to a microservices-based architecture bank application

A microservices-based architecture bank application that includes back-end and front-end applications, as well as a...

Traceability

Introducing Olive Oil Trust: smart contracts

Olive Oil Trust smart contracts are implemented in order to adopt a set of rules...


Ready to #buidl?

Are you interested in Web3 or the synergies between blockchain technology, artificial intelligence and zero knowledge?. Then, do not hesitate to contact me by e-mail or on my LinkedIn profile. You can also find me on GitHub.