> ## 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.

# ERC-20 Interaction

> Reading and writing ERC-20 tokens on Sei with viem and ethers

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>;
};

# ERC-20 interaction

Standard ERC-20 contracts work on Sei without modification. This page shows the common read and write operations with viem and ethers.

## Try it: deploy an ERC-20

Compile and deploy a real ERC-20 to Sei Testnet from the browser. The Remix sandbox preloads an OpenZeppelin-based `DemoToken`. Then use the read and write patterns below with your deployed address.

First, add Sei Testnet to your wallet:

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

In Remix, compile the contract on the **Solidity Compiler** tab. Then deploy it from the **Deploy & Run** tab with **Environment** set to **Injected Provider - MetaMask**.

<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." />

## Setup

<CodeGroup>
  ```ts viem theme={null}
  import { createPublicClient, createWalletClient, http, parseAbi } from 'viem';
  import { privateKeyToAccount } from 'viem/accounts';
  import { sei } from 'viem/chains';

  const client = createPublicClient({ chain: sei, transport: http() });
  const account = privateKeyToAccount('0xYourPrivateKey');
  const walletClient = createWalletClient({ account, chain: sei, transport: http() });

  const ERC20_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)',
    'function allowance(address owner, address spender) view returns (uint256)',
    'function transfer(address to, uint256 amount) returns (bool)',
    'function approve(address spender, uint256 amount) returns (bool)',
    'function transferFrom(address from, address to, uint256 amount) returns (bool)',
    'event Transfer(address indexed from, address indexed to, uint256 value)',
    'event Approval(address indexed owner, address indexed spender, uint256 value)',
  ]);

  const TOKEN = '0xTokenAddress';
  ```

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

  const provider = new ethers.JsonRpcProvider('https://evm-rpc.sei-apis.com');
  const wallet = new ethers.Wallet('0xYourPrivateKey', provider);

  const ERC20_ABI = [
    '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)',
    'function allowance(address owner, address spender) view returns (uint256)',
    'function transfer(address to, uint256 amount) returns (bool)',
    'function approve(address spender, uint256 amount) returns (bool)',
    'function transferFrom(address from, address to, uint256 amount) returns (bool)',
    'event Transfer(address indexed from, address indexed to, uint256 value)',
    'event Approval(address indexed owner, address indexed spender, uint256 value)',
  ];

  const TOKEN = '0xTokenAddress';
  const readContract = new ethers.Contract(TOKEN, ERC20_ABI, provider);
  const writeContract = new ethers.Contract(TOKEN, ERC20_ABI, wallet);
  ```
</CodeGroup>

## Reading token metadata

<CodeGroup>
  ```ts viem theme={null}
  const [name, symbol, decimals, totalSupply] = await Promise.all([
    client.readContract({ address: TOKEN, abi: ERC20_ABI, functionName: 'name' }),
    client.readContract({ address: TOKEN, abi: ERC20_ABI, functionName: 'symbol' }),
    client.readContract({ address: TOKEN, abi: ERC20_ABI, functionName: 'decimals' }),
    client.readContract({ address: TOKEN, abi: ERC20_ABI, functionName: 'totalSupply' }),
  ]);
  ```

  ```ts ethers theme={null}
  const [name, symbol, decimals, totalSupply] = await Promise.all([
    readContract.name(),
    readContract.symbol(),
    readContract.decimals(),
    readContract.totalSupply(),
  ]);
  ```
</CodeGroup>

## Reading balances and allowances

<CodeGroup>
  ```ts viem theme={null}
  const balance = await client.readContract({
    address: TOKEN,
    abi: ERC20_ABI,
    functionName: 'balanceOf',
    args: ['0xOwnerAddress'],
  });

  const allowance = await client.readContract({
    address: TOKEN,
    abi: ERC20_ABI,
    functionName: 'allowance',
    args: ['0xOwnerAddress', '0xSpenderAddress'],
  });
  ```

  ```ts ethers theme={null}
  const balance = await readContract.balanceOf('0xOwnerAddress');
  const allowance = await readContract.allowance('0xOwnerAddress', '0xSpenderAddress');
  ```
</CodeGroup>

## Transferring tokens

<CodeGroup>
  ```ts viem theme={null}
  import { parseUnits } from 'viem';

  const hash = await walletClient.writeContract({
    address: TOKEN,
    abi: ERC20_ABI,
    functionName: 'transfer',
    args: ['0xRecipient', parseUnits('10', 18)],
  });

  const receipt = await client.waitForTransactionReceipt({ hash });
  ```

  ```ts ethers theme={null}
  const tx = await writeContract.transfer('0xRecipient', ethers.parseUnits('10', 18));
  const receipt = await tx.wait();
  ```
</CodeGroup>

## Approving a spender

<CodeGroup>
  ```ts viem theme={null}
  import { parseUnits, maxUint256 } from 'viem';

  // Approve a specific amount
  const hash = await walletClient.writeContract({
    address: TOKEN,
    abi: ERC20_ABI,
    functionName: 'approve',
    args: ['0xSpenderAddress', parseUnits('100', 18)],
  });

  // Or approve max (unlimited)
  const hashMax = await walletClient.writeContract({
    address: TOKEN,
    abi: ERC20_ABI,
    functionName: 'approve',
    args: ['0xSpenderAddress', maxUint256],
  });
  ```

  ```ts ethers theme={null}
  // Approve a specific amount
  const tx = await writeContract.approve('0xSpenderAddress', ethers.parseUnits('100', 18));

  // Or approve max (unlimited)
  const txMax = await writeContract.approve('0xSpenderAddress', ethers.MaxUint256);
  ```
</CodeGroup>

## Watching transfer events

Watch new events over the WebSocket endpoint. Watchers that poll the public HTTP endpoint can miss events or report them twice. For details, see [Listen to events](/evm/cookbook/listen-to-events).

<CodeGroup>
  ```ts viem theme={null}
  import { webSocket } from 'viem';

  const wsClient = createPublicClient({ chain: sei, transport: webSocket('wss://evm-ws.sei-apis.com') });

  const unwatch = wsClient.watchContractEvent({
    address: TOKEN,
    abi: ERC20_ABI,
    eventName: 'Transfer',
    onLogs: (logs) => {
      logs.forEach(({ args }) => {
        console.log(`${args.from} → ${args.to}: ${args.value}`);
      });
    },
  });

  // Stop watching
  unwatch();
  ```

  ```ts ethers theme={null}
  const wsProvider = new ethers.WebSocketProvider('wss://evm-ws.sei-apis.com');
  const wsContract = new ethers.Contract(TOKEN, ERC20_ABI, wsProvider);

  wsContract.on('Transfer', (from, to, value) => {
    console.log(`${from} → ${to}: ${value}`);
  });

  // Stop watching
  wsContract.off('Transfer');
  ```
</CodeGroup>

## Fetching historical transfers

The public endpoints return logs for at most 2,000 blocks per request, so query a bounded range. This example reads the latest 2,000 blocks:

<CodeGroup>
  ```ts viem theme={null}
  const latest = await client.getBlockNumber();

  const logs = await client.getContractEvents({
    address: TOKEN,
    abi: ERC20_ABI,
    eventName: 'Transfer',
    fromBlock: latest - 1999n,
    toBlock: latest,
  });
  ```

  ```ts ethers theme={null}
  const latest = await provider.getBlockNumber();

  const filter = readContract.filters.Transfer();
  const logs = await readContract.queryFilter(filter, latest - 1999, latest);
  ```
</CodeGroup>

To go further back, request consecutive ranges of 2,000 blocks or fewer, or use an [indexer](/learn/indexers).

## CosmWasm token compatibility

CW20 tokens on Sei have ERC-20 pointer contracts that expose the standard ERC-20 interface. You can use all of the patterns above with a CW20 pointer address. To look up the pointer address, see [Pointer Contracts](/evm/evm-parity/examples/pointer-contracts).


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