Atharva Deosthale Web Developer and Designer | JavaScript = ❤ | MERN Stack Developer

How to build an ERC-20 exchange platform

10 min read 2915

Applications of tokens and NFTs are increasing everyday. They bring a lot of new possibilities to the table. From using them in games to creating your own virtual currency for an event, you can do many things using Web3 technologies.

Today, we are going to make a token exchange platform, where users can buy and sell a specific token. The token type we’re going to use here is ERC-20, and we are going to deploy them on the Polygon Mumbai network.

The approach we are going to follow is first creating a smart contract, which will mint our tokens, and then another smart contract to facilitate buying and selling them. We will then use both of the smart contracts in our Next.js application in order to make buying and selling accessible to the users.

Prerequisites

  • Working knowledge of React
  • Working knowledge of Next.js
  • Working knowledge of Solidity
  • Node.js installed
  • A code editor – I prefer Visual Studio Code
  • MetaMask extension installed with at least one wallet
  • MetaMask connected to Polygon Mumbai testnet

If you feel you’re stuck in the tutorial, feel free to refer to the GitHub repository.

Contents

Creating the token smart contract

The token smart contract will help us mint tokens. Open Remix IDE and create a new file called TestToken.sol under the contracts folder. Use the following code in the file:

// SPDX-License-Identifier: Unlicense
pragma solidity ^0.8.4;
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
contract TestToken is ERC20 {
    constructor() ERC20("TestToken", "TEST"){
        _mint(msg.sender, 10000 * 10 ** 18);
    }
}

In the above code, we are using OpenZeppelin contract templates to create an ERC-20 token. These templates provide us with bare minimum functions required by an ERC-20 tokens, and are secure and optimized, so you don’t need to worry about using tightening the security of your token. In the constructor of our contract, we are specifying the name and the symbol of our token and minting 10,000 tokens into the contract creator’s wallet.

Now, compile the contract by pressing Ctrl + S (or Cmd + S for Mac). Make sure you have connected MetaMask to Polygon Mumbai. Go to the Deploy tab in the sidebar, and set the environment to Injected Web3, so that Remix IDE uses MetaMask to deploy your contract.

Make sure your other settings are similar to this:

metamask settings

After verifying all the settings, click on Deploy. This should open up MetaMask authorization popup. Click on Confirm.



If you don’t have funds in your Polygon Mumbai wallet, you can get some through a faucet. After confirming the transaction, wait for a few seconds until you see your contract deployed on the left sidebar.

Copy the contract address, as we will need it when configuring the vendor contract.

Creating the vendor contract

Create a new file called TestTokenVendor.sol under the contracts folder. Use the following code for the contract:

// SPDX-License-Identifier: Unlicense
pragma solidity ^0.8.4;
import "./TestToken.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
contract Vendor is Ownable {
  TestToken yourToken;
  uint256 public tokensPerMatic = 100;
  event BuyTokens(address buyer, uint256 amountOfMATIC, uint256 amountOfTokens);
  constructor(address tokenAddress) {
    yourToken = TestToken(tokenAddress);
  }

  function buyTokens() public payable returns (uint256 tokenAmount) {
    require(msg.value > 0, "You need to send some MATIC to proceed");
    uint256 amountToBuy = msg.value * tokensPerMatic;

    uint256 vendorBalance = yourToken.balanceOf(address(this));
    require(vendorBalance >= amountToBuy, "Vendor has insufficient tokens");

    (bool sent) = yourToken.transfer(msg.sender, amountToBuy);
    require(sent, "Failed to transfer token to user");

    emit BuyTokens(msg.sender, msg.value, amountToBuy);
    return amountToBuy;
  }
  function sellTokens(uint256 tokenAmountToSell) public {

    require(tokenAmountToSell > 0, "Specify an amount of token greater than zero");

    uint256 userBalance = yourToken.balanceOf(msg.sender);
    require(userBalance >= tokenAmountToSell, "You have insufficient tokens");

    uint256 amountOfMATICToTransfer = tokenAmountToSell / tokensPerMatic;
    uint256 ownerMATICBalance = address(this).balance;
    require(ownerMATICBalance >= amountOfMATICToTransfer, "Vendor has insufficient funds");
    (bool sent) = yourToken.transferFrom(msg.sender, address(this), tokenAmountToSell);
    require(sent, "Failed to transfer tokens from user to vendor");

    (sent,) = msg.sender.call{value: amountOfMATICToTransfer}("");
    require(sent, "Failed to send MATIC to the user");
  }

  function withdraw() public onlyOwner {
    uint256 ownerBalance = address(this).balance;
    require(ownerBalance > 0, "No MATIC present in Vendor");
    (bool sent,) = msg.sender.call{value: address(this).balance}("");
    require(sent, "Failed to withdraw");
  }
}

In the above code, we are creating an Ownable contract, which is an OpenZeppelin template. This means that we can transfer the ownership of this contract to some other wallet, if we wish to do so.

We are importing our token contract and providing the address to it through the constructor (we will provide the address as an argument while deploying the contract). We are also setting a general price of the token inside the tokensPerMatic variable. This value will be used to calculate the amount of tokens to purchase based on the amount of MATIC sent to the contract.

In the buyTokens() function, we are firstly checking if any MATIC was sent and if the vendor contract has sufficient tokens in balance or not. Then we are doing yourToken.transfer() to send our tokens from the vendor contract to the wallet that sent the contract call. Finally, we are emitting an event and returning the number of tokens purchased.

The sellTokens() function is not as straightforward as the buyTokens() function. We perform checks on whether user has the specified number of tokens or not, calculate the amount of MATIC to be sent back to the user, and check whether the vendor contract has sufficient amount of MATIC in balance.

However, here comes the fun part: we can’t just transfer tokens from user’s wallet to our vendor contract’s balance. We first need to ask for approval from the user to let us manage their tokens and send these tokens back to us. This method is secure because we need to set the limit of number of tokens we are asking approval for. The authorization is on MetaMask and the user can clearly see what amount of tokens is the contract allowed to handle.


More great articles from LogRocket:


This approval procedure takes place on the front end (of a Next.js application, in this case). After the approval is granted, we perform the transferFrom() function to transfer the user’s funds to vendor contract’s wallet. And finally, we send MATIC to the user.

The withdraw() function can only be run by the owner of the contract. This function allows you to send all the MATIC stored in the smart contract into the owner’s wallet.

Finally, deploy the contract and pass the token contract’s address as a parameter.

Configuring the vendor smart contract

Now that we have deployed the vendor smart contract, we need to send it some tokens to work.

Open the token contract in the Deploy tab. Under the transfer section, paste the vendor contract’s address and the total amount of tokens (which can be obtained by clicking totalSupply.

Click on transact to approve the MetaMask transaction, and all the tokens will be sent from your wallet to the vendor contract balance. Now your vendor contract is able to facilitate buying and selling of tokens.

Now, go to the compile tab, and copy the ABI of the two contracts (as well as token and vendor addresses), as we will need them in our Next.js file.

Setting up our Next.js app

Navigate to a safe directory and run the following command in the terminal to create your Next.js app:

npx create-next-app erc20-exchange-platform

Use --use-npm at the end if you wish to install dependencies using npm (create-next-app has recently defaulted to yarn). Navigate to the project folder and run the following command to install some required dependencies:

#npm
npm install @thirdweb-dev/sdk @thirdweb-dev/react web3
#yarn
yarn add @thirdweb-dev/sdk @thirdweb-dev/react web3

We are installing @thirdweb-dev/sdk to help with authentication and connecting our application to MetaMask. This way, we don’t spend a lot of time in authentication.

We are also installing @thirdweb-dev/react to provide us Hooks that will work with @thirdweb-dev/sdk to give us useful information about the state of the user. We are installing web3 to interact with our token and vendor smart contracts.

As we won’t be covering styling in this tutorial, I’ll just leave it here without focusing too much on the details. Open globals.css under the styles folder and use the following styles:

html,
body {
  padding: 0;
  margin: 0;
  font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen,
    Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif;
}
a {
  color: inherit;
  text-decoration: none;
}
* {
  box-sizing: border-box;
}
.home__container {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  min-height: 100vh;
}
.home__button {
  background-color: #2ecc71;
  border: none;
  border-radius: 5px;
  font-size: 20px;
  padding: 15px;
  margin: 10px;
  cursor: pointer;
}
.exchange__container {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  min-height: 100vh;
}
.exchange__textBox {
  width: 300px;
  height: 50px;
  border: 1px solid #c4c4c4;
  border-radius: 5px;
  font-size: 20px;
  padding: 15px;
  margin: 10px;
  cursor: pointer;
}
.exchange__button {
  width: 300px;
  height: 50px;
  border: 1px solid #2ecc71;
  background-color: #2ecc71;
  border-radius: 5px;
  font-size: 20px;
  padding: 15px;
  margin: 10px;
  cursor: pointer;
}

Setting up environment variables and other data

Create a new file called .env.local in the project directory, this will hold our contract addresses:

NEXT_PUBLIC_TOKEN_CONTRACT_ADDRESS=(token address here)
NEXT_PUBLIC_VENDOR_CONTRACT_ADDRESS=(vendor address here)

Now, create a new file called contracts.js in the project directory. In this file, we will save and export the contract ABIs for both contracts:

export const tokenABI = [
  {
    inputs: [],
    stateMutability: "nonpayable",
    type: "constructor",
  },
  {
    anonymous: false,
    inputs: [
      {
        indexed: true,
        internalType: "address",
        name: "owner",
        type: "address",
      },
      {
        indexed: true,
        internalType: "address",
        name: "spender",
        type: "address",
      },
      {
        indexed: false,
        internalType: "uint256",
        name: "value",
        type: "uint256",
      },
    ],
    name: "Approval",
    type: "event",
  },
  {
    anonymous: false,
    inputs: [
      {
        indexed: true,
        internalType: "address",
        name: "from",
        type: "address",
      },
      {
        indexed: true,
        internalType: "address",
        name: "to",
        type: "address",
      },
      {
        indexed: false,
        internalType: "uint256",
        name: "value",
        type: "uint256",
      },
    ],
    name: "Transfer",
    type: "event",
  },
  {
    inputs: [
      {
        internalType: "address",
        name: "owner",
        type: "address",
      },
      {
        internalType: "address",
        name: "spender",
        type: "address",
      },
    ],
    name: "allowance",
    outputs: [
      {
        internalType: "uint256",
        name: "",
        type: "uint256",
      },
    ],
    stateMutability: "view",
    type: "function",
  },
  {
    inputs: [
      {
        internalType: "address",
        name: "spender",
        type: "address",
      },
      {
        internalType: "uint256",
        name: "amount",
        type: "uint256",
      },
    ],
    name: "approve",
    outputs: [
      {
        internalType: "bool",
        name: "",
        type: "bool",
      },
    ],
    stateMutability: "nonpayable",
    type: "function",
  },
  {
    inputs: [
      {
        internalType: "address",
        name: "account",
        type: "address",
      },
    ],
    name: "balanceOf",
    outputs: [
      {
        internalType: "uint256",
        name: "",
        type: "uint256",
      },
    ],
    stateMutability: "view",
    type: "function",
  },
  {
    inputs: [],
    name: "decimals",
    outputs: [
      {
        internalType: "uint8",
        name: "",
        type: "uint8",
      },
    ],
    stateMutability: "view",
    type: "function",
  },
  {
    inputs: [
      {
        internalType: "address",
        name: "spender",
        type: "address",
      },
      {
        internalType: "uint256",
        name: "subtractedValue",
        type: "uint256",
      },
    ],
    name: "decreaseAllowance",
    outputs: [
      {
        internalType: "bool",
        name: "",
        type: "bool",
      },
    ],
    stateMutability: "nonpayable",
    type: "function",
  },
  {
    inputs: [
      {
        internalType: "address",
        name: "spender",
        type: "address",
      },
      {
        internalType: "uint256",
        name: "addedValue",
        type: "uint256",
      },
    ],
    name: "increaseAllowance",
    outputs: [
      {
        internalType: "bool",
        name: "",
        type: "bool",
      },
    ],
    stateMutability: "nonpayable",
    type: "function",
  },
  {
    inputs: [],
    name: "name",
    outputs: [
      {
        internalType: "string",
        name: "",
        type: "string",
      },
    ],
    stateMutability: "view",
    type: "function",
  },
  {
    inputs: [],
    name: "symbol",
    outputs: [
      {
        internalType: "string",
        name: "",
        type: "string",
      },
    ],
    stateMutability: "view",
    type: "function",
  },
  {
    inputs: [],
    name: "totalSupply",
    outputs: [
      {
        internalType: "uint256",
        name: "",
        type: "uint256",
      },
    ],
    stateMutability: "view",
    type: "function",
  },
  {
    inputs: [
      {
        internalType: "address",
        name: "to",
        type: "address",
      },
      {
        internalType: "uint256",
        name: "amount",
        type: "uint256",
      },
    ],
    name: "transfer",
    outputs: [
      {
        internalType: "bool",
        name: "",
        type: "bool",
      },
    ],
    stateMutability: "nonpayable",
    type: "function",
  },
  {
    inputs: [
      {
        internalType: "address",
        name: "from",
        type: "address",
      },
      {
        internalType: "address",
        name: "to",
        type: "address",
      },
      {
        internalType: "uint256",
        name: "amount",
        type: "uint256",
      },
    ],
    name: "transferFrom",
    outputs: [
      {
        internalType: "bool",
        name: "",
        type: "bool",
      },
    ],
    stateMutability: "nonpayable",
    type: "function",
  },
];
export const vendorABI = [
  {
    inputs: [
      {
        internalType: "address",
        name: "tokenAddress",
        type: "address",
      },
    ],
    stateMutability: "nonpayable",
    type: "constructor",
  },
  {
    anonymous: false,
    inputs: [
      {
        indexed: false,
        internalType: "address",
        name: "buyer",
        type: "address",
      },
      {
        indexed: false,
        internalType: "uint256",
        name: "amountOfETH",
        type: "uint256",
      },
      {
        indexed: false,
        internalType: "uint256",
        name: "amountOfTokens",
        type: "uint256",
      },
    ],
    name: "BuyTokens",
    type: "event",
  },
  {
    anonymous: false,
    inputs: [
      {
        indexed: true,
        internalType: "address",
        name: "previousOwner",
        type: "address",
      },
      {
        indexed: true,
        internalType: "address",
        name: "newOwner",
        type: "address",
      },
    ],
    name: "OwnershipTransferred",
    type: "event",
  },
  {
    inputs: [],
    name: "buyTokens",
    outputs: [
      {
        internalType: "uint256",
        name: "tokenAmount",
        type: "uint256",
      },
    ],
    stateMutability: "payable",
    type: "function",
  },
  {
    inputs: [],
    name: "owner",
    outputs: [
      {
        internalType: "address",
        name: "",
        type: "address",
      },
    ],
    stateMutability: "view",
    type: "function",
  },
  {
    inputs: [],
    name: "renounceOwnership",
    outputs: [],
    stateMutability: "nonpayable",
    type: "function",
  },
  {
    inputs: [
      {
        internalType: "uint256",
        name: "tokenAmountToSell",
        type: "uint256",
      },
    ],
    name: "sellTokens",
    outputs: [],
    stateMutability: "nonpayable",
    type: "function",
  },
  {
    inputs: [],
    name: "tokensPerEth",
    outputs: [
      {
        internalType: "uint256",
        name: "",
        type: "uint256",
      },
    ],
    stateMutability: "view",
    type: "function",
  },
  {
    inputs: [
      {
        internalType: "address",
        name: "newOwner",
        type: "address",
      },
    ],
    name: "transferOwnership",
    outputs: [],
    stateMutability: "nonpayable",
    type: "function",
  },
  {
    inputs: [],
    name: "withdraw",
    outputs: [],
    stateMutability: "nonpayable",
    type: "function",
  },
];

Working on authentication

Go to _app.js file under the pages folder. Here, we will enclose our app with ThirdwebProvider, which will help us implement authentication.

Your _app.js should look like this:

import { ThirdwebProvider, ChainId } from "@thirdweb-dev/react";
import "../styles/globals.css";
function MyApp({ Component, pageProps }) {
  return (
    <ThirdwebProvider desiredChainId={ChainId.Mumbai}>
      <Component {...pageProps} />
    </ThirdwebProvider>
  );
}
export default MyApp;

Here, we are providing the desiredChainId as the chain ID of Polygon Mumbai. Now go to index.js and copy the following code in it:

import { useAddress, useMetamask } from "@thirdweb-dev/react";
import Head from "next/head";
import Image from "next/image";
import styles from "../styles/Home.module.css";
import { useEffect } from "react";
import { useRouter } from "next/router";
export default function Home() {
  const connectWithMetamask = useMetamask();
  const router = useRouter();
  const address = useAddress();
  useEffect(() => {
    if (address) router.replace("/exchange");
  }, [address]);
  return (
    <div>
      <Head>
        <title>Exchange TEST tokens</title>
        <link rel="icon" href="/favicon.ico" />
      </Head>
      <div className="home__container">
        <h1>Sign in to exchange</h1>
        <button className="home__button" onClick={connectWithMetamask}>
          Sign in using MetaMask
        </button>
      </div>
    </div>
  );
}

Above, we are using Hooks from Thirdweb to perform authentication using a button and keeping track of the address of the user’s wallet. We are also checking if the user is authenticated in an useEffect() and redirecting the user accordingly.

Now create a new file called exchange.js in the pages directory. Have the following layout in the file:

import { useAddress } from "@thirdweb-dev/react";
import Head from "next/head";
import { useRouter } from "next/router";
import React, { useEffect, useState } from "react";
import Web3 from "web3";
import { tokenABI, vendorABI } from "../contracts";
const web3 = new Web3(Web3.givenProvider);
function Exchange() {
  const [tokens, setTokens] = useState();
  const [matic, setMatic] = useState(0);
  const address = useAddress();
  const router = useRouter();
  const purchase = async () => {};
  const sell = async () => {};
  useEffect(() => {
    if (!address) router.replace("/");
  }, [address]);
  useEffect(() => {
    setMatic(tokens / 100);
  }, [tokens]);
  return (
    <div>
      <Head>
        <title>Exchange TEST tokens</title>
      </Head>
      <div className="exchange__container">
        <h1>Purchase TEST Tokens</h1>
        <input
          type="number"
          placeholder="Amount of tokens"
          className="exchange__textBox"
          value={tokens}
          onChange={(e) => setTokens(e.target.value)}
        />
        <div>MATIC equivalent: {matic}</div>
        <button className="exchange__button" onClick={purchase}>
          Purchase
        </button>
        <button className="exchange__button" onClick={sell}>
          Sell
        </button>
      </div>
    </div>
  );
}
export default Exchange;

The above code is checking if the user is authenticated or not, and calculating how many MATIC is required to purchase tokens. The code should provide a result as follows:

Purchase test tokens screen

As you type in the number of tokens, the amount of MATIC required will be updated. We also have initialized the web3 package before the component, so we can use contract-based operations now.

Implementing purchasing and selling functionality

Now, let’s work on the actual functionality of purchasing and selling. In the above code, we have no code for purchase() function. Here’s the code for it:

const purchase = async () => {
  try {
    const accounts = await web3.eth.getAccounts();
    const vendor = new web3.eth.Contract(
      vendorABI,
      process.env.NEXT_PUBLIC_VENDOR_CONTRACT_ADDRESS
    );
    const request = await vendor.methods.buyTokens().send({
      from: accounts[0],
      value: web3.utils.toWei(matic.toString(), "ether"),
    });
    alert("You have successfully purchased TEST tokens!");
    console.log(request);
  } catch (err) {
    console.error(err);
    alert("Error purchasing tokens");
  }
};

In the above code, we first get the accounts connected with MetaMask. Then, we initialize our vendor contract by providing the contract ABI and address in the parameters of the constructor.

Then, we call the buyTokens() function of our contract and send the required amount of MATIC along with the request. Now when you attempt to purchase tokens, MetaMask authorization should pop up and, upon accepting it, you should see tokens in your MetaMask assets shortly:

number of test tokens

The code for sell() is as follows:

const sell = async () => {
  try {
    const accounts = await web3.eth.getAccounts();
    const tokenContract = new web3.eth.Contract(
      tokenABI,
      process.env.NEXT_PUBLIC_TOKEN_CONTRACT_ADDRESS
    );
    // Approve the contract to spend the tokens
    let request = await tokenContract.methods
      .approve(
        process.env.NEXT_PUBLIC_VENDOR_CONTRACT_ADDRESS,
        web3.utils.toWei(tokens, "ether")
      )
      .send({
        from: accounts[0],
      });
    // Trigger the selling of tokens
    const vendor = new web3.eth.Contract(
      vendorABI,
      process.env.NEXT_PUBLIC_VENDOR_CONTRACT_ADDRESS
    );
    request = await vendor.methods
      .sellTokens(web3.utils.toWei(tokens, "ether"))
      .send({
        from: accounts[0],
      });
    alert("You have successfully sold TEST tokens!");
    console.log(request);
  } catch (err) {
    console.error(err);
    alert("Error selling tokens");
  }
};

In the above code, we are contacting the token contract to approve the vendor contract to spend required tokens on our behalf. Then, we are contacting our vendor contract to initiate the selling process. The tokens will then be transferred to the vendor and MATIC will be sent to the user’s wallet.

Conclusion

Congratulations! You’ve successfully created your own ERC-20 token exchange platform. Here’s the GitHub repository if you feel you’re stuck somewhere in the tutorial.

I suggest trying out something new, adding some features and making the platform more interactive, this will help you learn concepts of Solidity even better.

Join organizations like Bitso and Coinsquare who use LogRocket to proactively monitor their Web3 apps

Client-side issues that impact users’ ability to activate and transact in your apps can drastically affect your bottom line. If you’re interested in monitoring UX issues, automatically surfacing JavaScript errors, and tracking slow network requests and component load time, try LogRocket.https://logrocket.com/signup/

LogRocket is like a DVR for web and mobile apps, recording everything that happens in your web app or site. Instead of guessing why problems happen, you can aggregate and report on key frontend performance metrics, replay user sessions along with application state, log network requests, and automatically surface all errors.

Modernize how you debug web and mobile apps — .

Atharva Deosthale Web Developer and Designer | JavaScript = ❤ | MERN Stack Developer

One Reply to “How to build an ERC-20 exchange platform”

  1. Currently doing some backend exchange simulation. The vendor contract was a very helpful starting point. Thanks!

Leave a Reply