> ## Documentation Index
> Fetch the complete documentation index at: https://seilabs-docs-evm-cookbook.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Deploy an ERC-20

> Write an ERC-20 token with OpenZeppelin Contracts, deploy it to Sei Testnet with Foundry, Hardhat, or Remix, then verify it and read it back.

export const AddSeiButton = ({network = 'mainnet', label = 'Add Sei to MetaMask'}) => {
  const SEI_MAINNET_CHAIN_PARAMS = {
    chainId: '0x531',
    chainName: 'Sei Network',
    rpcUrls: ['https://evm-rpc.sei-apis.com'],
    nativeCurrency: {
      name: 'Sei',
      symbol: 'SEI',
      decimals: 18
    },
    blockExplorerUrls: ['https://seiscan.io']
  };
  const SEI_TESTNET_CHAIN_PARAMS = {
    chainId: '0x530',
    chainName: 'Sei Testnet',
    rpcUrls: ['https://evm-rpc-testnet.sei-apis.com'],
    nativeCurrency: {
      name: 'Sei',
      symbol: 'SEI',
      decimals: 18
    },
    blockExplorerUrls: ['https://testnet.seiscan.io']
  };
  const chainParams = network === 'testnet' ? SEI_TESTNET_CHAIN_PARAMS : SEI_MAINNET_CHAIN_PARAMS;
  const [status, setStatus] = useState(null);
  const [isHovered, setIsHovered] = useState(false);
  const [isBusy, setIsBusy] = useState(false);
  const addOrSwitchSeiNetwork = async params => {
    if (typeof window === 'undefined' || !window.ethereum) {
      throw new Error('MetaMask is not installed');
    }
    const ethereum = window.ethereum;
    try {
      await ethereum.request({
        method: 'wallet_switchEthereumChain',
        params: [{
          chainId: params.chainId
        }]
      });
    } catch (switchError) {
      if (switchError && switchError.code === 4902) {
        await ethereum.request({
          method: 'wallet_addEthereumChain',
          params: [params]
        });
      } else {
        throw switchError;
      }
    }
  };
  const onClick = async e => {
    e.preventDefault();
    setIsBusy(true);
    setStatus(null);
    try {
      await addOrSwitchSeiNetwork(chainParams);
      setStatus({
        type: 'success',
        message: `${chainParams.chainName} added or switched.`
      });
    } catch (err) {
      const message = err && err.message ? err.message : 'Failed to add or switch network.';
      setStatus({
        type: 'error',
        message
      });
    } finally {
      setIsBusy(false);
    }
  };
  return <span className="inline-flex flex-col items-start gap-1">
      <button type="button" onClick={onClick} disabled={isBusy} onMouseEnter={() => setIsHovered(true)} onMouseLeave={() => setIsHovered(false)} className="inline-flex items-center gap-1 px-3 py-1.5 text-white transition-colors min-w-[160px]" style={{
    backgroundColor: isHovered ? 'var(--sei-maroon-200)' : 'var(--sei-maroon-100)',
    color: '#ffffff',
    fontFamily: 'var(--sei-font-mono)',
    textTransform: 'uppercase',
    letterSpacing: '0.04em',
    fontSize: '10px',
    opacity: isBusy ? 0.7 : 1,
    cursor: isBusy ? 'default' : 'pointer'
  }}>
        {isBusy ? 'Adding…' : label}
      </button>
      {status && (status.type === 'error' ? <span className="text-red-600 dark:text-red-400" style={{
    fontSize: '11px'
  }}>
            {status.message}
          </span> : <span className="text-green-700 dark:text-green-400" style={{
    fontSize: '11px'
  }}>
            {status.message}
          </span>)}
    </span>;
};

export const SandboxEmbed = props => {
  const {src, kind = 'codesandbox', title, description, height, label} = props || ({});
  const KINDS = {
    codesandbox: {
      name: 'CodeSandbox',
      host: 'codesandbox.io',
      defaultHeight: 500
    },
    remix: {
      name: 'Remix IDE',
      host: 'remix.ethereum.org',
      defaultHeight: 620
    },
    stackblitz: {
      name: 'StackBlitz',
      host: 'stackblitz.com',
      defaultHeight: 500
    }
  };
  const meta = KINDS[kind] || KINDS.codesandbox;
  const parsedHeight = Number(height);
  const frameHeight = Number.isFinite(parsedHeight) && parsedHeight > 0 ? parsedHeight : meta.defaultHeight;
  const allowAttr = 'clipboard-read; clipboard-write';
  const [frameSrc, setFrameSrc] = useState(null);
  const [btnHover, setBtnHover] = useState(false);
  const [isDark, setIsDark] = useState(true);
  useLayoutEffect(() => {
    const el = document.documentElement;
    const update = () => setIsDark(el.classList.contains('dark'));
    update();
    const obs = new MutationObserver(update);
    obs.observe(el, {
      attributes: true,
      attributeFilter: ['class']
    });
    return () => obs.disconnect();
  }, []);
  const themedSrc = (() => {
    if (!src) return src;
    try {
      const url = new URL(src);
      url.searchParams.set('theme', isDark ? 'dark' : 'light');
      return url.toString();
    } catch {
      return src;
    }
  })();
  const loadEditor = () => {
    if (themedSrc) setFrameSrc(themedSrc);
  };
  const loaded = frameSrc !== null;
  const HAIRLINE = 'rgba(128, 128, 128, 0.25)';
  const surfaceStyle = {
    backgroundColor: 'rgba(128, 128, 128, 0.08)'
  };
  const monoStyle = {
    fontFamily: 'var(--sei-font-mono)'
  };
  const buttonStyle = {
    backgroundColor: btnHover ? 'var(--sei-maroon-200)' : 'var(--sei-maroon-100)',
    color: '#ffffff',
    fontFamily: 'var(--sei-font-mono)',
    textTransform: 'uppercase',
    letterSpacing: '0.04em',
    fontSize: '10px',
    cursor: 'pointer'
  };
  const PlayIcon = () => <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
			<path d="M8 5v14l11-7z" />
		</svg>;
  const ExternalIcon = () => <svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
			<path d="M14 5h5v5" />
			<path d="M19 5l-9 9" />
			<path d="M19 14v5a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V8a1 1 0 0 1 1-1h5" />
		</svg>;
  return <div className="not-prose w-full rounded-lg border overflow-hidden my-4" style={{
    borderColor: HAIRLINE
  }}>
			<div className="flex items-center justify-between gap-3 px-4 py-2.5 border-b" style={{
    ...surfaceStyle,
    borderBottomColor: HAIRLINE
  }}>
				<div className="flex flex-col min-w-0">
					<span className="text-sm font-medium text-neutral-900 dark:text-white truncate" style={monoStyle}>
						{title || meta.name}
					</span>
					<span className="text-xs text-neutral-600 dark:text-neutral-400">{meta.name}</span>
				</div>
				<div className="flex items-center gap-3 shrink-0">
					{themedSrc ? <a href={themedSrc} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-1 text-xs text-neutral-500 hover:text-neutral-800 dark:text-neutral-400 dark:hover:text-neutral-200 transition-colors">
							Open <ExternalIcon />
						</a> : null}
					{!loaded && themedSrc ? <button type="button" onClick={loadEditor} onMouseEnter={() => setBtnHover(true)} onMouseLeave={() => setBtnHover(false)} className="inline-flex items-center gap-1.5 px-3 py-1.5 transition-colors" style={buttonStyle}>
							<PlayIcon />
							{label || 'Load editor'}
						</button> : null}
				</div>
			</div>

			{description ? <div className="px-4 pt-3 pb-1 text-sm text-neutral-600 dark:text-neutral-400">{description}</div> : null}

			{!themedSrc ? <div className="px-4 py-6 text-sm text-red-600 dark:text-red-400" style={monoStyle}>
					SandboxEmbed: missing required `src`.
				</div> : loaded ? <iframe src={frameSrc} title={title || meta.name} className="w-full block border-0" style={{
    height: frameHeight + 'px',
    backgroundColor: 'rgba(128, 128, 128, 0.05)'
  }} allow={allowAttr} loading="lazy" allowFullScreen /> : <button type="button" onClick={loadEditor} className="w-full flex flex-col items-center justify-center gap-2 text-neutral-600 dark:text-neutral-400 hover:text-neutral-800 dark:hover:text-neutral-200 transition-colors" style={{
    height: frameHeight + 'px',
    cursor: 'pointer',
    ...surfaceStyle
  }}>
					<PlayIcon />
					<span className="text-sm" style={monoStyle}>Click to load {meta.name}</span>
					<span className="text-xs">Loads {meta.host} in an embedded editor</span>
				</button>}
		</div>;
};

This recipe deploys `DemoToken`, a fixed-supply ERC-20 token built on [OpenZeppelin Contracts](https://docs.openzeppelin.com/contracts/5.x/erc20). Standard ERC-20 contracts work on Sei without changes.

## Before you start

* A Sei Testnet account that holds some SEI for gas. Get testnet SEI from the [faucet](/learn/faucet). Use a throwaway account that holds only testnet funds.
* For Foundry, the account's private key in the `PRIVATE_KEY` environment variable.
* For Hardhat, the private key in the Hardhat keystore, as the [Hardhat guide](/evm/evm-hardhat#configuring-hardhat-for-sei-evm) shows.
* For Remix, a browser wallet such as MetaMask.

## The contract

```solidity DemoToken.sol theme={null}
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";

contract DemoToken is ERC20 {
    constructor() ERC20("Demo Token", "DEMO") {
        _mint(msg.sender, 1_000_000 * 10 ** decimals());
    }
}
```

The constructor mints 1,000,000 DEMO to the account that deploys the contract. `decimals()` returns 18, so the contract stores the supply as 1,000,000 × 10<sup>18</sup> base units.

## Deploy the contract

<Tabs>
  <Tab title="Foundry">
    Create a project and install OpenZeppelin Contracts:

    ```bash theme={null}
    forge init demo-token
    cd demo-token
    forge install OpenZeppelin/openzeppelin-contracts
    ```

    Save the contract as `src/DemoToken.sol`. Then deploy it:

    ```bash theme={null}
    forge create src/DemoToken.sol:DemoToken \
      --rpc-url https://evm-rpc-testnet.sei-apis.com \
      --private-key $PRIVATE_KEY \
      --broadcast
    ```

    Foundry prints the token address after `Deployed to:`.

    <Note>Without `--broadcast`, `forge create` only simulates the deployment. It prints the transaction, but it does not send it.</Note>

    For project settings, tests, and deploy scripts, see the [Foundry guide](/evm/evm-foundry).
  </Tab>

  <Tab title="Hardhat">
    Start from a Hardhat 3 project that has Sei Testnet configured as the `seitestnet` network. The [Hardhat guide](/evm/evm-hardhat#configuring-hardhat-for-sei-evm) shows the configuration and how to store your key in the Hardhat keystore. Then install OpenZeppelin Contracts:

    ```bash theme={null}
    npm install @openzeppelin/contracts
    ```

    Save the contract as `contracts/DemoToken.sol`. Then describe the deployment in an Ignition module:

    ```ts ignition/modules/DemoToken.ts theme={null}
    import { buildModule } from '@nomicfoundation/hardhat-ignition/modules';

    export default buildModule('DemoTokenModule', (m) => {
      const token = m.contract('DemoToken');
      return { token };
    });
    ```

    Deploy the module:

    ```bash theme={null}
    npx hardhat ignition deploy ignition/modules/DemoToken.ts --network seitestnet
    ```

    Ignition prints the token address next to `DemoTokenModule#DemoToken`.
  </Tab>

  <Tab title="Remix">
    Remix compiles and deploys from the browser, so you do not install anything. First, add Sei Testnet to your wallet:

    <AddSeiButton network="testnet" label="Add Sei Testnet" />

    The sandbox below preloads `DemoToken`. Compile it on the **Solidity Compiler** tab. Then deploy it from the **Deploy & Run** tab with **Environment** set to **Injected Provider - MetaMask**. Remix shows the token address under **Deployed Contracts**.

    <SandboxEmbed kind="remix" src="https://remix.ethereum.org/?#activate=solidity,fileManager&code=Ly8gU1BEWC1MaWNlbnNlLUlkZW50aWZpZXI6IE1JVApwcmFnbWEgc29saWRpdHkgXjAuOC4yNDsKCmltcG9ydCAiQG9wZW56ZXBwZWxpbi9jb250cmFjdHMvdG9rZW4vRVJDMjAvRVJDMjAuc29sIjsKCmNvbnRyYWN0IERlbW9Ub2tlbiBpcyBFUkMyMCB7CiAgICBjb25zdHJ1Y3RvcigpIEVSQzIwKCJEZW1vIFRva2VuIiwgIkRFTU8iKSB7CiAgICAgICAgX21pbnQobXNnLnNlbmRlciwgMV8wMDBfMDAwICogMTAgKiogZGVjaW1hbHMoKSk7CiAgICB9Cn0" title="DemoToken (ERC-20) · deploy to Sei Testnet" description="Remix IDE with an OpenZeppelin ERC-20 preloaded. Compile in the browser and deploy to Sei Testnet." />
  </Tab>
</Tabs>

## Verify the source

Verification publishes your source code on [Seiscan](https://testnet.seiscan.io), so that anyone can read the contract. Sourcify verification does not need an API key. Replace `0xYourTokenAddress` with the deployed address:

<CodeGroup>
  ```bash Foundry theme={null}
  forge verify-contract \
    --verifier sourcify \
    --chain-id 1328 \
    0xYourTokenAddress \
    src/DemoToken.sol:DemoToken
  ```

  ```bash Hardhat theme={null}
  npx hardhat verify sourcify --network seitestnet 0xYourTokenAddress
  ```
</CodeGroup>

For Remix and the other verification options, see [Verify contracts](/evm/evm-verify-contracts).

## Read the token back

Read the token's metadata and your balance to confirm the deployment. Replace `0xYourTokenAddress` with the token address and `0xYourAddress` with the deployer address.

<CodeGroup>
  ```ts viem theme={null}
  import { createPublicClient, http, parseAbi, formatUnits } from 'viem';
  import { seiTestnet } from 'viem/chains';

  const client = createPublicClient({ chain: seiTestnet, transport: http() });

  const token = '0xYourTokenAddress';
  const owner = '0xYourAddress';
  const abi = parseAbi([
    'function name() view returns (string)',
    'function symbol() view returns (string)',
    'function decimals() view returns (uint8)',
    'function totalSupply() view returns (uint256)',
    'function balanceOf(address owner) view returns (uint256)',
  ]);

  const [name, symbol, decimals, totalSupply, balance] = await Promise.all([
    client.readContract({ address: token, abi, functionName: 'name' }),
    client.readContract({ address: token, abi, functionName: 'symbol' }),
    client.readContract({ address: token, abi, functionName: 'decimals' }),
    client.readContract({ address: token, abi, functionName: 'totalSupply' }),
    client.readContract({ address: token, abi, functionName: 'balanceOf', args: [owner] }),
  ]);

  console.log(`${name} (${symbol})`);
  console.log('Total supply:', formatUnits(totalSupply, decimals));
  console.log('Your balance:', formatUnits(balance, decimals));
  ```

  ```ts ethers theme={null}
  import { ethers } from 'ethers';

  const provider = new ethers.JsonRpcProvider('https://evm-rpc-testnet.sei-apis.com');

  const token = new ethers.Contract(
    '0xYourTokenAddress',
    [
      'function name() view returns (string)',
      'function symbol() view returns (string)',
      'function decimals() view returns (uint8)',
      'function totalSupply() view returns (uint256)',
      'function balanceOf(address owner) view returns (uint256)',
    ],
    provider
  );
  const owner = '0xYourAddress';

  const [name, symbol, decimals, totalSupply, balance] = await Promise.all([
    token.name(),
    token.symbol(),
    token.decimals(),
    token.totalSupply(),
    token.balanceOf(owner),
  ]);

  console.log(`${name} (${symbol})`);
  console.log('Total supply:', ethers.formatUnits(totalSupply, decimals));
  console.log('Your balance:', ethers.formatUnits(balance, decimals));
  ```

  ```python web3.py theme={null}
  from decimal import Decimal
  from web3 import Web3

  w3 = Web3(Web3.HTTPProvider("https://evm-rpc-testnet.sei-apis.com"))

  def view(name, inputs, output):
      return {"type": "function", "name": name, "stateMutability": "view",
              "inputs": [{"name": n, "type": t} for n, t in inputs],
              "outputs": [{"name": "", "type": output}]}

  abi = [
      view("name", [], "string"),
      view("symbol", [], "string"),
      view("decimals", [], "uint8"),
      view("totalSupply", [], "uint256"),
      view("balanceOf", [("owner", "address")], "uint256"),
  ]
  token = w3.eth.contract(address="0xYourTokenAddress", abi=abi)
  owner = "0xYourAddress"

  scale = Decimal(10) ** token.functions.decimals().call()
  print(f"{token.functions.name().call()} ({token.functions.symbol().call()})")
  print("Total supply:", Decimal(token.functions.totalSupply().call()) / scale)
  print("Your balance:", Decimal(token.functions.balanceOf(owner).call()) / scale)
  ```
</CodeGroup>

**You are done when you see:**

```text theme={null}
Demo Token (DEMO)
Total supply: 1000000
Your balance: 1000000
```

ethers prints `1000000.0`, because `formatUnits` in ethers always includes a decimal place.

## Related recipes

* [ERC-20 interaction](/evm/evm-parity/examples/erc20): transfers, approvals, and allowances
* [Listen to events](/evm/cookbook/listen-to-events): stream the token's `Transfer` events
* [Deploy and verify](/evm/evm-parity/examples/deploy-verify): deploy any contract with viem, ethers, Foundry, or Hardhat


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.