Skip to main content

Deploy an ERC20 Token

Learn how to create, deploy, and interact with your own ERC20 token on Nexis Appchain. This tutorial covers token creation with OpenZeppelin, deployment strategies, and best practices.

Prerequisites

Wallet Setup

MetaMask or compatible wallet connected to Nexis Testnet

Testnet Tokens

Get testnet NZT from the faucet

Development Tools

Hardhat or Foundry installed locally

Basic Solidity

Understanding of Solidity and smart contracts

Network Configuration

Add Nexis Testnet to your development environment:
require("@nomicfoundation/hardhat-toolbox");
require("dotenv").config();

module.exports = {
  solidity: {
    version: "0.8.20",
    settings: {
      optimizer: {
        enabled: true,
        runs: 200
      }
    }
  },
  networks: {
    nexisTestnet: {
      url: "https://testnet-rpc.nex-t1.ai",
      chainId: 84532,
      accounts: [process.env.PRIVATE_KEY],
      gasPrice: 1000000000, // 1 gwei
    },
    nexisMainnet: {
      url: "https://rpc.nex-t1.ai",
      chainId: 84532,
      accounts: [process.env.PRIVATE_KEY],
    }
  },
  etherscan: {
    apiKey: {
      nexisTestnet: "your-api-key"
    }
  }
};

Token Contract

Create a feature-rich ERC20 token using OpenZeppelin:
1

Install Dependencies

Install OpenZeppelin contracts library
# For Hardhat
npm install @openzeppelin/contracts

# For Foundry
forge install OpenZeppelin/openzeppelin-contracts
2

Create Token Contract

Create your token contract with essential features
3

Add Extensions

Implement minting, burning, pausing, and access control
4

Test & Deploy

Write comprehensive tests before mainnet deployment

Basic ERC20 Token

contracts/MyToken.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

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

/**
 * @title MyToken
 * @dev Basic ERC20 token with fixed supply
 */
contract MyToken is ERC20, Ownable {
    uint256 public constant INITIAL_SUPPLY = 1_000_000 * 10**18; // 1 million tokens

    constructor() ERC20("My Token", "MTK") Ownable(msg.sender) {
        _mint(msg.sender, INITIAL_SUPPLY);
    }
}

Advanced Token with Features

contracts/AdvancedToken.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/token/ERC20/extensions/ERC20Burnable.sol";
import "@openzeppelin/contracts/token/ERC20/extensions/ERC20Pausable.sol";
import "@openzeppelin/contracts/access/AccessControl.sol";
import "@openzeppelin/contracts/token/ERC20/extensions/ERC20Permit.sol";

/**
 * @title AdvancedToken
 * @dev Feature-rich ERC20 token with:
 * - Minting (controlled)
 * - Burning
 * - Pausing
 * - Role-based access control
 * - EIP-2612 Permit (gasless approvals)
 */
contract AdvancedToken is
    ERC20,
    ERC20Burnable,
    ERC20Pausable,
    AccessControl,
    ERC20Permit
{
    bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE");
    bytes32 public constant PAUSER_ROLE = keccak256("PAUSER_ROLE");

    uint256 public constant MAX_SUPPLY = 10_000_000 * 10**18; // 10 million cap

    event TokensMinted(address indexed to, uint256 amount);
    event TokensBurned(address indexed from, uint256 amount);

    constructor(
        string memory name,
        string memory symbol,
        uint256 initialSupply
    ) ERC20(name, symbol) ERC20Permit(name) {
        require(initialSupply <= MAX_SUPPLY, "Initial supply exceeds max");

        _grantRole(DEFAULT_ADMIN_ROLE, msg.sender);
        _grantRole(MINTER_ROLE, msg.sender);
        _grantRole(PAUSER_ROLE, msg.sender);

        if (initialSupply > 0) {
            _mint(msg.sender, initialSupply);
        }
    }

    /**
     * @dev Mint new tokens (only MINTER_ROLE)
     */
    function mint(address to, uint256 amount) public onlyRole(MINTER_ROLE) {
        require(totalSupply() + amount <= MAX_SUPPLY, "Exceeds max supply");
        _mint(to, amount);
        emit TokensMinted(to, amount);
    }

    /**
     * @dev Pause all token transfers (only PAUSER_ROLE)
     */
    function pause() public onlyRole(PAUSER_ROLE) {
        _pause();
    }

    /**
     * @dev Unpause token transfers (only PAUSER_ROLE)
     */
    function unpause() public onlyRole(PAUSER_ROLE) {
        _unpause();
    }

    /**
     * @dev Burn tokens from caller
     */
    function burn(uint256 amount) public override {
        super.burn(amount);
        emit TokensBurned(msg.sender, amount);
    }

    // Required overrides
    function _update(
        address from,
        address to,
        uint256 value
    ) internal override(ERC20, ERC20Pausable) {
        super._update(from, to, value);
    }
}

Token with Tax Mechanism

contracts/TaxToken.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

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

/**
 * @title TaxToken
 * @dev ERC20 token with configurable buy/sell tax
 */
contract TaxToken is ERC20, Ownable {
    uint256 public buyTaxPercent = 5;   // 5% buy tax
    uint256 public sellTaxPercent = 5;  // 5% sell tax
    address public taxReceiver;

    mapping(address => bool) public isExcludedFromTax;
    mapping(address => bool) public isPair; // DEX pairs

    event TaxCollected(address indexed from, uint256 amount);
    event TaxRatesUpdated(uint256 buyTax, uint256 sellTax);

    constructor(
        string memory name,
        string memory symbol,
        uint256 initialSupply,
        address _taxReceiver
    ) ERC20(name, symbol) Ownable(msg.sender) {
        require(_taxReceiver != address(0), "Invalid tax receiver");

        taxReceiver = _taxReceiver;
        isExcludedFromTax[msg.sender] = true;
        isExcludedFromTax[_taxReceiver] = true;

        _mint(msg.sender, initialSupply);
    }

    function _update(
        address from,
        address to,
        uint256 amount
    ) internal override {
        // Skip tax if sender or receiver is excluded
        if (isExcludedFromTax[from] || isExcludedFromTax[to]) {
            super._update(from, to, amount);
            return;
        }

        uint256 taxAmount = 0;

        // Buy tax (from pair to user)
        if (isPair[from] && buyTaxPercent > 0) {
            taxAmount = (amount * buyTaxPercent) / 100;
        }
        // Sell tax (from user to pair)
        else if (isPair[to] && sellTaxPercent > 0) {
            taxAmount = (amount * sellTaxPercent) / 100;
        }

        if (taxAmount > 0) {
            super._update(from, taxReceiver, taxAmount);
            emit TaxCollected(from, taxAmount);
            amount -= taxAmount;
        }

        super._update(from, to, amount);
    }

    function setTaxRates(uint256 _buyTax, uint256 _sellTax) external onlyOwner {
        require(_buyTax <= 25 && _sellTax <= 25, "Tax too high");
        buyTaxPercent = _buyTax;
        sellTaxPercent = _sellTax;
        emit TaxRatesUpdated(_buyTax, _sellTax);
    }

    function setTaxReceiver(address _taxReceiver) external onlyOwner {
        require(_taxReceiver != address(0), "Invalid address");
        taxReceiver = _taxReceiver;
    }

    function excludeFromTax(address account, bool excluded) external onlyOwner {
        isExcludedFromTax[account] = excluded;
    }

    function setPair(address pairAddress, bool value) external onlyOwner {
        isPair[pairAddress] = value;
    }
}

Deployment Scripts

const hre = require("hardhat");

async function main() {
  const [deployer] = await ethers.getSigners();

  console.log("Deploying token with account:", deployer.address);
  console.log("Account balance:", ethers.formatEther(await ethers.provider.getBalance(deployer.address)));

  // Deploy AdvancedToken
  const AdvancedToken = await ethers.getContractFactory("AdvancedToken");
  const token = await AdvancedToken.deploy(
    "Nexis Token",           // name
    "NXT",                   // symbol
    ethers.parseEther("1000000") // initial supply (1M tokens)
  );

  await token.waitForDeployment();
  const tokenAddress = await token.getAddress();

  console.log("Token deployed to:", tokenAddress);
  console.log("Token name:", await token.name());
  console.log("Token symbol:", await token.symbol());
  console.log("Total supply:", ethers.formatEther(await token.totalSupply()));

  // Verify contract (optional)
  if (network.name !== "hardhat" && network.name !== "localhost") {
    console.log("Waiting for block confirmations...");
    await token.deploymentTransaction().wait(5);

    console.log("Verifying contract...");
    try {
      await hre.run("verify:verify", {
        address: tokenAddress,
        constructorArguments: [
          "Nexis Token",
          "NXT",
          ethers.parseEther("1000000")
        ],
      });
    } catch (error) {
      console.log("Verification error:", error.message);
    }
  }

  // Save deployment info
  const fs = require('fs');
  const deploymentInfo = {
    network: network.name,
    token: tokenAddress,
    deployer: deployer.address,
    timestamp: new Date().toISOString(),
    blockNumber: await ethers.provider.getBlockNumber()
  };

  fs.writeFileSync(
    `deployments/${network.name}-token.json`,
    JSON.stringify(deploymentInfo, null, 2)
  );

  console.log("Deployment info saved to deployments/");
}

main()
  .then(() => process.exit(0))
  .catch((error) => {
    console.error(error);
    process.exit(1);
  });

Deploy Your Token

  • Hardhat
  • Foundry
# Deploy to testnet
npx hardhat run scripts/deploy-token.js --network nexisTestnet

# Deploy to mainnet
npx hardhat run scripts/deploy-token.js --network nexisMainnet

Interact with Your Token

const { ethers } = require("ethers");

const provider = new ethers.JsonRpcProvider("https://testnet-rpc.nex-t1.ai");
const wallet = new ethers.Wallet(process.env.PRIVATE_KEY, provider);

const tokenAddress = "0x..."; // Your deployed token
const tokenABI = [...]; // Your token ABI

const token = new ethers.Contract(tokenAddress, tokenABI, wallet);

// Check balance
const balance = await token.balanceOf(wallet.address);
console.log("Balance:", ethers.formatEther(balance));

// Transfer tokens
const transferTx = await token.transfer(
  "0xRecipientAddress",
  ethers.parseEther("100")
);
await transferTx.wait();
console.log("Transfer complete:", transferTx.hash);

// Approve spending
const approveTx = await token.approve(
  "0xSpenderAddress",
  ethers.parseEther("1000")
);
await approveTx.wait();

// Mint new tokens (if you have MINTER_ROLE)
const mintTx = await token.mint(
  wallet.address,
  ethers.parseEther("10000")
);
await mintTx.wait();
console.log("Minted 10,000 tokens");

// Burn tokens
const burnTx = await token.burn(ethers.parseEther("100"));
await burnTx.wait();
console.log("Burned 100 tokens");

Testing Your Token

Write comprehensive tests before deployment:
test/Token.test.js
const { expect } = require("chai");
const { ethers } = require("hardhat");

describe("AdvancedToken", function () {
  let token;
  let owner;
  let addr1;
  let addr2;

  beforeEach(async function () {
    [owner, addr1, addr2] = await ethers.getSigners();

    const Token = await ethers.getContractFactory("AdvancedToken");
    token = await Token.deploy(
      "Test Token",
      "TST",
      ethers.parseEther("1000000")
    );
  });

  describe("Deployment", function () {
    it("Should set the correct name and symbol", async function () {
      expect(await token.name()).to.equal("Test Token");
      expect(await token.symbol()).to.equal("TST");
    });

    it("Should mint initial supply to owner", async function () {
      const balance = await token.balanceOf(owner.address);
      expect(balance).to.equal(ethers.parseEther("1000000"));
    });

    it("Should grant roles to deployer", async function () {
      const DEFAULT_ADMIN_ROLE = await token.DEFAULT_ADMIN_ROLE();
      const MINTER_ROLE = await token.MINTER_ROLE();

      expect(await token.hasRole(DEFAULT_ADMIN_ROLE, owner.address)).to.be.true;
      expect(await token.hasRole(MINTER_ROLE, owner.address)).to.be.true;
    });
  });

  describe("Transfers", function () {
    it("Should transfer tokens between accounts", async function () {
      await token.transfer(addr1.address, ethers.parseEther("100"));
      expect(await token.balanceOf(addr1.address)).to.equal(ethers.parseEther("100"));

      await token.connect(addr1).transfer(addr2.address, ethers.parseEther("50"));
      expect(await token.balanceOf(addr2.address)).to.equal(ethers.parseEther("50"));
    });

    it("Should fail when sender has insufficient balance", async function () {
      await expect(
        token.connect(addr1).transfer(addr2.address, ethers.parseEther("1"))
      ).to.be.reverted;
    });
  });

  describe("Minting", function () {
    it("Should mint tokens with MINTER_ROLE", async function () {
      await token.mint(addr1.address, ethers.parseEther("1000"));
      expect(await token.balanceOf(addr1.address)).to.equal(ethers.parseEther("1000"));
    });

    it("Should not exceed max supply", async function () {
      const maxSupply = await token.MAX_SUPPLY();
      const currentSupply = await token.totalSupply();
      const mintAmount = maxSupply - currentSupply + ethers.parseEther("1");

      await expect(
        token.mint(addr1.address, mintAmount)
      ).to.be.revertedWith("Exceeds max supply");
    });

    it("Should fail without MINTER_ROLE", async function () {
      await expect(
        token.connect(addr1).mint(addr2.address, ethers.parseEther("100"))
      ).to.be.reverted;
    });
  });

  describe("Burning", function () {
    it("Should burn tokens", async function () {
      await token.transfer(addr1.address, ethers.parseEther("1000"));
      await token.connect(addr1).burn(ethers.parseEther("500"));

      expect(await token.balanceOf(addr1.address)).to.equal(ethers.parseEther("500"));
    });
  });

  describe("Pausing", function () {
    it("Should pause and unpause transfers", async function () {
      await token.pause();

      await expect(
        token.transfer(addr1.address, ethers.parseEther("100"))
      ).to.be.reverted;

      await token.unpause();

      await token.transfer(addr1.address, ethers.parseEther("100"));
      expect(await token.balanceOf(addr1.address)).to.equal(ethers.parseEther("100"));
    });
  });
});
Run tests:
# Hardhat
npx hardhat test

# Foundry
forge test -vvv

Best Practices

  • Use OpenZeppelin audited contracts
  • Implement access control for privileged functions
  • Set reasonable max supply caps
  • Add reentrancy guards for complex transfers
  • Test thoroughly before mainnet deployment
  • Consider getting a professional audit for tokens with tax mechanisms
  • Use uint256 instead of smaller uints (counter-intuitive but cheaper)
  • Cache storage variables in memory
  • Use events instead of storing data on-chain when possible
  • Minimize storage writes
  • Batch operations when possible
  • Choose appropriate initial supply and max cap
  • Consider vesting schedules for team/investors
  • Plan liquidity provision strategy
  • Set reasonable tax rates (if applicable)
  • Implement anti-whale mechanisms if needed
  • Consider using proxy patterns for upgradeability
  • Document all state variables for upgrade compatibility
  • Test upgrade paths thoroughly
  • Have emergency pause mechanisms
  • Plan for governance if upgrades are community-controlled

Common Use Cases

Governance Token

// Add voting power tracking
import "@openzeppelin/contracts/token/ERC20/extensions/ERC20Votes.sol";

contract GovernanceToken is ERC20, ERC20Votes, Ownable {
    constructor()
        ERC20("Governance Token", "GOV")
        ERC20Permit("Governance Token")
        Ownable(msg.sender)
    {
        _mint(msg.sender, 10_000_000 * 10**18);
    }

    function _update(address from, address to, uint256 value)
        internal
        override(ERC20, ERC20Votes)
    {
        super._update(from, to, value);
    }

    function nonces(address owner)
        public
        view
        override(ERC20Permit, Nonces)
        returns (uint256)
    {
        return super.nonces(owner);
    }
}

Wrapped Token

// Wrap native NZT to ERC20
contract WrappedNZT is ERC20 {
    event Deposit(address indexed account, uint256 amount);
    event Withdrawal(address indexed account, uint256 amount);

    constructor() ERC20("Wrapped NZT", "wNZT") {}

    function deposit() public payable {
        _mint(msg.sender, msg.value);
        emit Deposit(msg.sender, msg.value);
    }

    function withdraw(uint256 amount) public {
        _burn(msg.sender, amount);
        payable(msg.sender).transfer(amount);
        emit Withdrawal(msg.sender, amount);
    }

    receive() external payable {
        deposit();
    }
}

Next Steps

Troubleshooting

Make sure you have enough testnet NZT. Get more from the faucet.
Ensure constructor arguments match exactly. Use --constructor-args flag with Foundry or provide them in Hardhat verify config.
Check if token is paused, if recipient is blacklisted, or if there are any transfer restrictions in your contract.
Increase gas limit or check for revert conditions. Use eth_estimateGas to debug.

Additional Resources


Ready for Production? Always conduct thorough testing and consider a professional audit before deploying to mainnet, especially for tokens with complex mechanics or large value.