SolidityEthereumWeb3Smart Contracts

ERC-20 Smart Contracts: From Development to Production

A practical walkthrough of deploying ERC-20 tokens on Ethereum and Polygon — gas optimization, testing with Hardhat, and integrating Web3 wallet connectivity.

R
Roni Sarkar
Mar 20267 min read

Deploying your first ERC-20 contract to a testnet is easy. Getting it production-ready on Ethereum mainnet and Polygon with a Node.js backend integration is another story. Here's everything I learned building this for Remit & Go.

The ERC-20 Standard

ERC-20 is a fungible token standard on Ethereum. Every DeFi protocol, exchange, and wallet understands it. You implement 6 core functions: totalSupply, balanceOf, transfer, transferFrom, approve, and allowance.

solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/token/ERC20/extensions/ERC20Burnable.sol";

contract RemitToken is ERC20, ERC20Burnable, Ownable {
    uint256 public constant MAX_SUPPLY = 100_000_000 * 10**18; // 100M tokens

    constructor(address initialOwner)
        ERC20("RemitToken", "RMT")
        Ownable(initialOwner)
    {}

    function mint(address to, uint256 amount) external onlyOwner {
        require(totalSupply() + amount <= MAX_SUPPLY, "Exceeds max supply");
        _mint(to, amount);
    }
}
💡 Always use OpenZeppelin contracts as a base. They're audited, well-tested, and widely trusted. Never write token logic from scratch.

Testing with Hardhat

Write exhaustive tests before touching mainnet. Hardhat gives you a local EVM environment, time manipulation, and gas reporting.

typescript
import { expect } from "chai";
import { ethers } from "hardhat";

describe("RemitToken", () => {
  it("Should mint tokens to owner", async () => {
    const [owner, user] = await ethers.getSigners();
    const Token = await ethers.getContractFactory("RemitToken");
    const token = await Token.deploy(owner.address);

    await token.mint(user.address, ethers.parseEther("1000"));
    expect(await token.balanceOf(user.address)).to.equal(
      ethers.parseEther("1000")
    );
  });

  it("Should not exceed max supply", async () => {
    // ...
    await expect(
      token.mint(user.address, ethers.parseEther("100000001"))
    ).to.be.revertedWith("Exceeds max supply");
  });
});

Deploying to Polygon for Low Gas Fees

Polygon is EVM-compatible, so your Solidity code runs unchanged. The gas fees are ~1000x cheaper than Ethereum mainnet — ideal for high-frequency remittance transfers.

  • Polygon Mumbai testnet → Polygon mainnet for the deploy flow
  • Bridge MATIC to your deployer wallet
  • Use the same contract ABI on both chains — same Solidity, different RPC
  • Hardhat network config handles multi-chain deploys cleanly
typescript
// hardhat.config.ts
networks: {
  polygon: {
    url: `https://polygon-mainnet.g.alchemy.com/v2/${ALCHEMY_KEY}`,
    accounts: [DEPLOYER_PRIVATE_KEY],
    gasPrice: 30_000_000_000, // 30 gwei
  },
  ethereum: {
    url: `https://eth-mainnet.g.alchemy.com/v2/${ALCHEMY_KEY}`,
    accounts: [DEPLOYER_PRIVATE_KEY],
  },
},

Integrating with NestJS via Web3.js

From the NestJS backend, we interact with deployed contracts using Web3.js. The key pattern: load the contract ABI + deployed address, then call methods.

typescript
@Injectable()
export class BlockchainService {
  private readonly web3: Web3;
  private readonly contract: Contract;

  constructor() {
    this.web3 = new Web3(process.env.POLYGON_RPC_URL);
    this.contract = new this.web3.eth.Contract(
      RemitTokenABI,
      process.env.CONTRACT_ADDRESS,
    );
  }

  async getBalance(walletAddress: string): Promise<string> {
    const balance = await this.contract.methods
      .balanceOf(walletAddress)
      .call();
    return this.web3.utils.fromWei(balance as string, 'ether');
  }

  async transfer(from: string, to: string, amount: string): Promise<string> {
    const amountWei = this.web3.utils.toWei(amount, 'ether');
    const tx = await this.contract.methods.transfer(to, amountWei).send({
      from,
      gas: 100000,
    });
    return tx.transactionHash as string;
  }
}

Security Checklist Before Mainnet

  • Audit with Slither (free static analysis) before any external audit
  • Re-entrancy guards on any function that sends ETH
  • Access control: use Ownable or AccessControl, not hardcoded addresses
  • Integer overflow: Solidity 0.8+ has built-in overflow protection
  • Test on testnet for at least 2 weeks with real user flows
  • Verify contract source on Etherscan/Polygonscan for transparency
  • Never store private keys in code or environment variables — use AWS KMS or HashiCorp Vault

Enjoyed this post?

Let's connect and talk engineering.

Get in touch