Skip to content
Learni
View all tutorials
Blockchain

How to Create an ERC-20 Token in Solidity in 2026

Lire en français

Introduction

In 2026, Solidity 0.8.26 dominates smart contract development on Ethereum and its Layer-2s like Base or Optimism, thanks to built-in overflow checks and post-Dencun gas optimizations. An ERC-20 token remains the cornerstone of DeFi: stablecoins, governance tokens, and LP rewards all rely on it. This intermediate tutorial guides you through implementing an ERC-20 from scratch with Hardhat—no OpenZeppelin at first—to master internal mechanics like mappings for balances, events for indexing, and modifiers for access control.

Why from scratch? Understand pitfalls (residual reentrancy, approve front-running) before using libraries. We'll build a token with controlled minting, secure transfer/approve, unit tests, and deployment to Sepolia testnet. At the end: a live contract verifiable on Etherscan, audit-ready. Time: 30 min setup + dev. Skills gained: gas profiling, NatSpec docs, deploy scripts. Ideal for Web2 → Web3 transition.

Prerequisites

  • Node.js 20+ and npm
  • MetaMask account with Sepolia ETH (Alchemy faucet)
  • VS Code with Solidity + Hardhat extensions
  • Blockchain basics: tx fees, blocks, ABI
  • JavaScript knowledge for deploy/test scripts

Initialize the Hardhat Project

terminal
mkdir mon-token-erc20 && cd mon-token-erc20
npm init -y
npm install --save-dev hardhat @nomicfoundation/hardhat-toolbox@latest
npm install @openzeppelin/contracts
npx hardhat init
# Select: Create a JavaScript project
# Yes for .gitignore, install deps
npx hardhat compile
# Check: Compiled successfully

This script sets up a Hardhat project ready for Solidity, with the toolbox for compiling, testing (Mocha/Chai), deploying, and verifying. OpenZeppelin installation is optional here but useful for comparison. Automate prompts; after init, contracts/, scripts/, and test/ folders are generated. Common error: Node <20 causes npm failures.

Understanding Hardhat Structure

Hardhat simulates a local blockchain (mainnet fork possible). contracts/: your .sol files; scripts/: JS deploy scripts; test/: unit tests; hardhat.config.js: networks, Solidity compiler version. Use npx hardhat console for live interaction. For 2026, enable viaIR for gas optimization (in config). Next: configure for Sepolia.

Configure hardhat.config.js for Testnets

hardhat.config.js
require('@nomicfoundation/hardhat-toolbox');

/** @type import('hardhat/config').HardhatUserConfig */
module.exports = {
  solidity: {
    version: '0.8.26',
    settings: {
      optimizer: {
        enabled: true,
        runs: 200
      },
      viaIR: true
    }
  },
  networks: {
    hardhat: {},
    sepolia: {
      url: 'https://sepolia.infura.io/v3/VOTRE_INFURA_KEY',
      accounts: ['0xVOTRE_PRIVATE_KEY_SANS_0x']
    }
  },
  etherscan: {
    apiKey: {
      sepolia: 'VOTRE_ETHERSCAN_API_KEY'
    }
  }
};

This config targets Solidity 0.8.26 with IR optimizer for ~10-20% gas savings in 2026. Replace with your free Infura/Etherscan keys and MetaMask private key (export without 0x). viaIR enables the new IR pipeline for bug fixes. Test with npx hardhat node for a localhost chain.

Create a Basic Token Contract

contracts/MonToken.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.26;

contract MonToken {
    string public name = 'Mon Token';
    string public symbol = 'MTK';
    uint8 public decimals = 18;
    uint256 public totalSupply;

    event Transfer(address indexed from, address indexed to, uint256 value);

    constructor(uint256 _initialSupply) {
        totalSupply = _initialSupply * 10 ** decimals;
    }

    function balanceOf(address _owner) public view returns (uint256 balance) {
        return 0; // À implémenter
    }
}

ERC-20 skeleton contract: metadata, totalSupply minted on deploy, standard Transfer event. Constructor scales supply by decimals (18 like ETH). view for gas-free reads. Compile: npx hardhat compile. Pitfall: Forgetting SPDX causes Remix warnings.

Add Balances and Transfer

Mappings store balances efficiently (O(1) access, optimized storage slots). Indexed Transfer event enables TheGraph/Subgraph queries. Next: implement secure transfer without reentrancy (checks-effects-interactions).

Implement balanceOf and transfer

contracts/MonToken.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.26;

contract MonToken {
    string public name = 'Mon Token';
    string public symbol = 'MTK';
    uint8 public decimals = 18;
    uint256 public totalSupply;
    mapping(address => uint256) public balanceOf;

    event Transfer(address indexed from, address indexed to, uint256 value);

    constructor(uint256 _initialSupply) {
        totalSupply = _initialSupply * 10 ** decimals;
        balanceOf[msg.sender] = totalSupply;
        emit Transfer(address(0), msg.sender, totalSupply);
    }

    function transfer(address _to, uint256 _value) public returns (bool success) {
        require(balanceOf[msg.sender] >= _value, 'Solde insuffisant');
        balanceOf[msg.sender] -= _value;
        balanceOf[_to] += _value;
        emit Transfer(msg.sender, _to, _value);
        return true;
    }
}

balanceOf mapping tracks balances; initial mint to deployer. Transfer follows CEI pattern: require before modify, emit after. SafeMath implicit in 0.8+. Test locally: npx hardhat console --network localhost. Pitfall: msg.sender not indexed for privacy.

Add approve and transferFrom

contracts/MonToken.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.26;

contract MonToken {
    string public name = 'Mon Token';
    string public symbol = 'MTK';
    uint8 public decimals = 18;
    uint256 public totalSupply;
    mapping(address => uint256) public balanceOf;
    mapping(address => mapping(address => uint256)) public allowance;

    event Transfer(address indexed from, address indexed to, uint256 value);
    event Approval(address indexed owner, address indexed spender, uint256 value);

    constructor(uint256 _initialSupply) {
        totalSupply = _initialSupply * 10 ** decimals;
        balanceOf[msg.sender] = totalSupply;
        emit Transfer(address(0), msg.sender, totalSupply);
    }

    function transfer(address _to, uint256 _value) public returns (bool success) {
        require(balanceOf[msg.sender] >= _value, 'Solde insuffisant');
        balanceOf[msg.sender] -= _value;
        balanceOf[_to] += _value;
        emit Transfer(msg.sender, _to, _value);
        return true;
    }

    function approve(address _spender, uint256 _value) public returns (bool success) {
        allowance[msg.sender][_spender] = _value;
        emit Approval(msg.sender, _spender, _value);
        return true;
    }

    function transferFrom(address _from, address _to, uint256 _value) public returns (bool success) {
        require(balanceOf[_from] >= _value, 'Solde insuffisant');
        require(allowance[_from][msg.sender] >= _value, 'Allowance insuffisant');
        balanceOf[_from] -= _value;
        balanceOf[_to] += _value;
        allowance[_from][msg.sender] -= _value;
        emit Transfer(_from, _to, _value);
        return true;
    }
}

Nested mapping for allowances; approve sets limit, transferFrom spends it. Events for DEXes like Uniswap V2. CEI protects against front-running (use ERC-2612 permit in prod). Now fully ERC-20 compliant. Gas: ~50k deploy.

Deployment Script

JS scripts use ethers.js v6 (2026 standard). Run npx hardhat run scripts/deploy.js --network sepolia after setting keys. Verify: npx hardhat verify --network sepolia CONTRACT_ADDRESS 1000000.

deploy.js Script

scripts/deploy.js
const hre = require('hardhat');

async function main() {
  const initialSupply = '1000000';
  const MonToken = await hre.ethers.getContractFactory('MonToken');
  const monToken = await MonToken.deploy(initialSupply);

  await monToken.waitForDeployment();
  console.log('MonToken déployé à :', await monToken.getAddress());
}

main().catch((error) => {
  console.error(error);
  process.exitCode = 1;
});

Deploys 1M tokens (scaled by 18 decimals). waitForDeployment() awaits tx confirmation. Logs address for verify/MetaMask add. On Sepolia: ~0.01 ETH, 2min blocktime. Add try/catch for prod.

Complete Unit Test

test/MonToken.js
const { expect } = require('chai');

const { ethers } = require('hardhat');

describe('MonToken', function () {
  it('Devrait mint initial supply au owner', async function () {
    const MonToken = await ethers.getContractFactory('MonToken');
    const monToken = await MonToken.deploy('1000');
    await monToken.waitForDeployment();

    const owner = await ethers.getSigners()[0];
    expect(await monToken.balanceOf(owner.address)).to.equal(1000n * 10n ** 18n);
  });

  it('Devrait transfer tokens', async function () {
    const [owner, addr1] = await ethers.getSigners();
    const MonToken = await ethers.getContractFactory('MonToken');
    const monToken = await MonToken.deploy('1000');
    await monToken.waitForDeployment();

    await monToken.connect(owner).transfer(addr1.address, 100n * 10n ** 18n);
    expect(await monToken.balanceOf(addr1.address)).to.equal(100n * 10n ** 18n);
  });
});

Chai tests: check mint, transfer. Run npx hardhat test. BigInt for uint256. Covers 80% edge cases; add approveFrom. Green tests = ready to deploy.

Best Practices

  • NatSpec: Add /// @notice, @dev for auto-generated docs.
  • OpenZeppelin: Inherit ERC20 in prod for pausability, upgrades.
  • Gas optimization: Pack storage slots (name+symbol adjacent), immutable vars.
  • Security: Use onlyOwner modifiers, Slither audits.
  • Verification: Etherscan + Sourcify post-deploy.

Common Errors to Avoid

  • Approve race: Set approve(0) then new amount to prevent front-running.
  • Decimals mismatch: Always *10**decimals in JS deploy.
  • Storage collision: Nested mappings gas-heavy; profile with npx hardhat run --gas.
  • No events: Breaks block explorers/TheGraph without Transfer/Approval.

Next Steps

Study OpenZeppelin Contracts for ERC20Permit, extensions. Switch to Foundry for faster tests. Advanced training: Learni Group Blockchain. Read Solidity Blog for 0.8.27 previews, explore Vyper alternative.