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

# Sei EVM Development with Hardhat

> Learn how to set up Hardhat for Sei EVM development, create and deploy smart contracts, and leverage OpenZeppelin components for secure, standardized implementations.

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

export const RunSnippet = props => {
  const {method = 'eth_blockNumber', params = [], network = 'testnet', endpoint, label, title, description, decode = 'auto'} = props || ({});
  const ENDPOINTS = {
    testnet: 'https://evm-rpc-testnet.sei-apis.com',
    mainnet: 'https://evm-rpc.sei-apis.com'
  };
  const rpcUrl = endpoint || ENDPOINTS[network] || ENDPOINTS.testnet;
  const networkLabel = network === 'mainnet' ? 'Sei Mainnet' : network === 'testnet' ? 'Sei Testnet' : network;
  const requestBody = {
    jsonrpc: '2.0',
    id: 1,
    method,
    params
  };
  const requestJson = JSON.stringify(requestBody, null, 2);
  const [phase, setPhase] = useState('idle');
  const [result, setResult] = useState(null);
  const [errorMsg, setErrorMsg] = useState(null);
  const [elapsed, setElapsed] = useState(null);
  const [copied, setCopied] = useState(false);
  const [btnHover, setBtnHover] = useState(false);
  const groupThousands = s => s.replace(/\B(?=(\d{3})+(?!\d))/g, ',');
  const hexToDecimal = value => {
    if (typeof value !== 'string' || !(/^0x[0-9a-fA-F]+$/).test(value)) return null;
    if (value.length > 66) return null;
    try {
      return groupThousands(BigInt(value).toString(10));
    } catch (e) {
      return null;
    }
  };
  const run = async () => {
    setPhase('loading');
    setErrorMsg(null);
    setResult(null);
    setElapsed(null);
    const startedAt = typeof performance !== 'undefined' ? performance.now() : null;
    try {
      const response = await fetch(rpcUrl, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json'
        },
        body: JSON.stringify(requestBody)
      });
      const data = await response.json();
      if (startedAt != null && typeof performance !== 'undefined') {
        setElapsed(Math.round(performance.now() - startedAt));
      }
      if (data && data.error) {
        setErrorMsg(data.error.message || 'RPC returned an error');
        setPhase('error');
        return;
      }
      setResult(data ? data.result : undefined);
      setPhase('success');
    } catch (err) {
      setErrorMsg(err && err.message ? err.message : 'Request failed');
      setPhase('error');
    }
  };
  const resultString = result === undefined ? 'undefined' : JSON.stringify(result, null, 2);
  const decoded = decode !== 'off' && typeof result === 'string' ? hexToDecimal(result) : null;
  const copyResult = () => {
    const flashCopied = () => {
      setCopied(true);
      setTimeout(() => setCopied(false), 2000);
    };
    if (typeof navigator !== 'undefined' && navigator.clipboard && navigator.clipboard.writeText) {
      navigator.clipboard.writeText(resultString).then(flashCopied, () => {});
      return;
    }
    if (typeof document !== 'undefined') {
      try {
        const ta = document.createElement('textarea');
        ta.value = resultString;
        ta.style.position = 'fixed';
        ta.style.opacity = '0';
        document.body.appendChild(ta);
        ta.select();
        document.execCommand('copy');
        document.body.removeChild(ta);
        flashCopied();
      } catch (e) {}
    }
  };
  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 codeStyle = {
    backgroundColor: 'rgba(128, 128, 128, 0.05)',
    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',
    opacity: phase === 'loading' ? 0.7 : 1,
    cursor: phase === 'loading' ? 'default' : 'pointer'
  };
  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 || method}
					</span>
					<span className="text-xs text-neutral-600 dark:text-neutral-400">{networkLabel}</span>
				</div>
				<button type="button" onClick={run} disabled={phase === 'loading'} onMouseEnter={() => setBtnHover(true)} onMouseLeave={() => setBtnHover(false)} className="inline-flex items-center gap-1.5 px-3 py-1.5 shrink-0 transition-colors" style={buttonStyle}>
					{phase === 'loading' ? <svg className="animate-spin h-4 w-4" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
								<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
								<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 0 1 8-8v4a4 4 0 0 0-4 4H4z" />
							</svg> : <svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
								<path d="M8 5v14l11-7z" />
							</svg>}
					{phase === 'loading' ? 'Running…' : label || 'Run'}
				</button>
			</div>

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

			<div className="px-4 pt-3 pb-1">
				<span className="text-xs uppercase tracking-wide text-neutral-600 dark:text-neutral-400">Request</span>
			</div>
			<pre className="m-0 px-4 py-3 text-sm overflow-x-auto text-neutral-700 dark:text-neutral-300" style={codeStyle}>
				{requestJson}
			</pre>

			{phase === 'success' ? <div className="border-t" style={{
    borderTopColor: HAIRLINE
  }}>
					<div className="flex items-center justify-between px-4 pt-3 pb-1">
						<span className="text-xs uppercase tracking-wide text-neutral-600 dark:text-neutral-400">Response{elapsed != null ? ` · ${elapsed} ms` : ''}</span>
						<button type="button" onClick={copyResult} className="text-xs text-neutral-500 hover:text-neutral-800 dark:text-neutral-400 dark:hover:text-neutral-200 transition-colors">
							{copied ? 'Copied' : 'Copy'}
						</button>
					</div>
					<pre className="m-0 px-4 py-3 text-sm overflow-x-auto text-neutral-700 dark:text-neutral-300" style={codeStyle}>
						{resultString}
					</pre>
					{decoded ? <div className="px-4 pb-3 text-xs text-neutral-600 dark:text-neutral-400" style={monoStyle}>
							= {decoded} (decimal)
						</div> : null}
				</div> : null}

			{phase === 'error' ? <div className="border-t" style={{
    borderTopColor: HAIRLINE
  }}>
					<div className="px-4 pt-3 pb-1">
						<span className="text-xs uppercase tracking-wide text-neutral-600 dark:text-neutral-400">Error</span>
					</div>
					<pre className="m-0 px-4 py-3 text-sm overflow-x-auto text-red-600 dark:text-red-400" style={codeStyle}>
						{errorMsg}
					</pre>
				</div> : null}
		</div>;
};

This tutorial shows how to set up Hardhat for Sei EVM development. It covers environment setup, contract creation, and deployment. You use OpenZeppelin's pre-built components to build secure, standardized smart contracts.

<Info>Watch the video walkthrough for this topic in the [Video Tutorials](/evm/videos) section.</Info>

<Tip>
  **Deploy to Sei Testnet first**

  We <strong>highly recommend</strong> that you deploy to <em>Sei Testnet</em> first. Make sure that everything works as expected before you deploy to Sei Mainnet. This helps you catch bugs early, avoid unnecessary gas costs, and keep your users safe.
</Tip>

## Try it before you install

The full toolchain takes a few minutes to set up. If you only want to see a contract deploy to Sei, you can do both of these steps in the browser now.

First, confirm that the RPC endpoints you will use in `hardhat.config` respond:

<RunSnippet method="eth_chainId" network="testnet" title="eth_chainId · Sei Testnet" description="Testnet RPC. The result decodes to 1328." />

<RunSnippet method="eth_chainId" network="mainnet" title="eth_chainId · Sei Mainnet" description="Mainnet RPC. The result decodes to 1329." />

Then add Sei Testnet to your wallet and deploy a minimal `Counter.sol` from Remix. You do not need Node.js or a local install. In Remix, compile the contract under **Solidity Compiler**. Then deploy it under **Deploy & Run**, with **Environment** set to **Injected Provider - MetaMask**.

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

<SandboxEmbed kind="remix" src="https://remix.ethereum.org/?#activate=solidity,fileManager&code=Ly8gU1BEWC1MaWNlbnNlLUlkZW50aWZpZXI6IE1JVApwcmFnbWEgc29saWRpdHkgXjAuOC4yNDsKCi8vLyBAdGl0bGUgQ291bnRlciDigJQgbWluaW1hbCBjb250cmFjdCB0byBjb21waWxlICYgZGVwbG95IHRvIFNlaSB0ZXN0bmV0Lgpjb250cmFjdCBDb3VudGVyIHsKICAgIHVpbnQyNTYgcHVibGljIGNvdW50OwogICAgZnVuY3Rpb24gaW5jcmVtZW50KCkgZXh0ZXJuYWwgeyBjb3VudCsrOyB9CiAgICBmdW5jdGlvbiBkZWNyZW1lbnQoKSBleHRlcm5hbCB7IGNvdW50LS07IH0KfQ" title="Counter.sol · deploy to Sei Testnet" description="Remix IDE with a minimal Counter contract preloaded. Compile in-browser, then deploy to Sei Testnet." />

## Table of contents

* [Try it before you install](#try-it-before-you-install)
* [Prerequisites](#prerequisites)
* [Setting up your development environment](#setting-up-your-development-environment)
* [Configuring Hardhat for Sei EVM](#configuring-hardhat-for-sei-evm)
* [Using OpenZeppelin contracts](#using-openzeppelin-contracts)
* [Creating and deploying an ERC20 token, ERC721 NFT or an upgradeable UUPS token](#creating-and-deploying-an-erc20-token)
* [Testing your smart contracts](#testing-your-smart-contracts)
* [Deploying to Sei Testnet and Mainnet](#deploying-to-sei-testnet-and-mainnet)

## Prerequisites

Before you start, make sure that you have installed these tools:

* [Node.js](https://nodejs.org/) (v18.0.0 or later)
* [npm](https://www.npmjs.com/) (v7.0.0 or later) or [yarn](https://yarnpkg.com/)
* A code editor (VS Code recommended)

## Setting up your development environment

Create a new project and set up Hardhat:

```bash theme={null}
# Create a new directory for your project
mkdir sei-hardhat-project
cd sei-hardhat-project

# Scaffold a Hardhat 3 project (ESM + TypeScript)
npx hardhat --init
```

When prompted, choose **Hardhat 3** and **a TypeScript project using Mocha and Ethers.js**. Then follow the prompts. This generates an ESM project (`"type": "module"` in `package.json`) with `@nomicfoundation/hardhat-toolbox-mocha-ethers`, `ethers`, Hardhat Ignition, TypeScript, and a ready-to-use `tsconfig.json`.

Then add the OpenZeppelin contract library:

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

<Tip>
  For a non-interactive setup (CI or scripted environments), do the same steps manually, without prompts:

  ```bash theme={null}
  npm init -y
  npm pkg set type=module
  npm install --save-dev hardhat @nomicfoundation/hardhat-toolbox-mocha-ethers @openzeppelin/contracts typescript @types/node
  ```

  The `@nomicfoundation/hardhat-toolbox-mocha-ethers` bundle includes `ethers`, Ignition, Mocha and Chai, and the keystore, network-helpers, verify, and typechain plugins. This single install covers everything in this guide. If you get peer-dependency errors, run the install again with `--legacy-peer-deps`.
</Tip>

## Configuring Hardhat for Sei EVM

Next, configure Hardhat to work with the Sei EVM. Update your `hardhat.config.ts` file:

```typescript title="hardhat.config.ts" theme={null}
import type { HardhatUserConfig } from 'hardhat/config';
import { configVariable } from 'hardhat/config';
import hardhatToolboxMochaEthers from '@nomicfoundation/hardhat-toolbox-mocha-ethers';

const config: HardhatUserConfig = {
  // Hardhat 3 loads plugins from an explicit array — no side-effect `import` statements
  plugins: [hardhatToolboxMochaEthers],
  solidity: {
    version: '0.8.28',
    settings: {
      optimizer: {
        enabled: true,
        runs: 200
      }
    }
  },
  networks: {
    // Sei Testnet
    seitestnet: {
      type: 'http',
      chainType: 'l1',
      url: 'https://evm-rpc-testnet.sei-apis.com',
      accounts: [configVariable('SEI_PRIVATE_KEY')],
      chainId: 1328
    },
    // Sei Mainnet
    seimainnet: {
      type: 'http',
      chainType: 'l1',
      url: 'https://evm-rpc.sei-apis.com',
      accounts: [configVariable('SEI_PRIVATE_KEY')],
      chainId: 1329
    }
  }
};

export default config;
```

<Note>Hardhat 3 is ESM-first and requires `"type": "module"` in `package.json`. The scaffold sets this for you. Each network needs an explicit `type: 'http'`. Plugins load through the `plugins` array, not through the Hardhat 2 side-effect `import '@nomicfoundation/hardhat-toolbox'`. A local simulated network is available automatically, so you do not need a `hardhat` or `localhost` entry.</Note>

Hardhat 3 reads secrets through `configVariable(...)`, which reads from an encrypted keystore. Hardhat never stores your key in a plaintext file. Set the key once:

```bash theme={null}
npx hardhat keystore set SEI_PRIVATE_KEY
```

Hardhat prompts you to paste the private key of the account that you deploy from. It stores the key encrypted on your machine. For CI, export a `SEI_PRIVATE_KEY` environment variable instead. `configVariable` falls back to this variable.

<Warning>Use a dedicated, throwaway deploy key that holds only the funds you need. Never use a personal wallet that holds real funds.</Warning>

## Using OpenZeppelin contracts

OpenZeppelin has a library of secure, tested smart contract components that you can use to build your dApps. You installed the `@openzeppelin/contracts` package during setup, so you can import its contracts directly.

## Creating and deploying an ERC20 token, ERC721 NFT or an upgradeable UUPS token

<Tabs>
  <Tab title="ERC20">
    To create a simple ERC20 token with OpenZeppelin contracts, add a new file named `SeiToken.sol` to the `contracts` directory:

    ```solidity title="contracts/SeiToken.sol" theme={null}
    // SPDX-License-Identifier: MIT
    pragma solidity ^0.8.22;

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

    contract SeiToken is ERC20, Ownable {
        constructor(address initialOwner)
            ERC20("Sei Token", "SEI")
            Ownable(initialOwner)
        {
            // Mint 1 million tokens to the contract deployer (with 18 decimals)
            _mint(msg.sender, 1000000 * 10 ** decimals());
        }

        // Function to mint new tokens (only owner)
        function mint(address to, uint256 amount) public onlyOwner {
            _mint(to, amount);
        }

        // Function to burn tokens
        function burn(uint256 amount) public {
            _burn(msg.sender, amount);
        }
    }
    ```

    Next, create a deployment script named `deploy-sei-token.ts` in the `ignition/modules` directory:

    ```typescript title="ignition/modules/deploy-sei-token.ts" theme={null}
    import { buildModule } from '@nomicfoundation/hardhat-ignition/modules';

    export default buildModule('SeiTokenModule', (m) => {
      const deployer = m.getAccount(0);

      const seiToken = m.contract('SeiToken', [deployer]);

      return { seiToken };
    });
    ```

    To deploy the token to Sei Testnet, run this command:

    ```bash theme={null}
    npx hardhat ignition deploy ignition/modules/deploy-sei-token.ts --network seitestnet
    ```
  </Tab>

  <Tab title="ERC721">
    To create an ERC721 NFT contract, add a new file named `SeiNFT.sol` to the `contracts` directory:

    ```solidity title="contracts/SeiNFT.sol" theme={null}
    // SPDX-License-Identifier: MIT
    // Compatible with OpenZeppelin Contracts ^5.0.0
    pragma solidity ^0.8.22;

    import {ERC721} from "@openzeppelin/contracts/token/ERC721/ERC721.sol";
    import {ERC721Burnable} from "@openzeppelin/contracts/token/ERC721/extensions/ERC721Burnable.sol";
    import {ERC721Enumerable} from "@openzeppelin/contracts/token/ERC721/extensions/ERC721Enumerable.sol";
    import {ERC721Pausable} from "@openzeppelin/contracts/token/ERC721/extensions/ERC721Pausable.sol";
    import {ERC721URIStorage} from "@openzeppelin/contracts/token/ERC721/extensions/ERC721URIStorage.sol";
    import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";

    contract SeiNFT is ERC721, ERC721Enumerable, ERC721URIStorage, ERC721Pausable, Ownable, ERC721Burnable {
        uint256 private _nextTokenId;

         // Base URI for metadata
        string private _baseTokenURI;

        constructor(address initialOwner, string memory baseTokenURI)
            ERC721("Sei NFT Collection", "SEINFT")
            Ownable(initialOwner)
        {
            _baseTokenURI = baseTokenURI;
        }

        // Function to update the base URI (only owner)
        function setBaseURI(string memory baseTokenURI) public onlyOwner {
            _baseTokenURI = baseTokenURI;
        }

        // Override the baseURI function
        function _baseURI() internal view override returns (string memory) {
            return _baseTokenURI;
        }

        function pause() public onlyOwner {
            _pause();
        }

        function unpause() public onlyOwner {
            _unpause();
        }

        function safeMint(address to, string memory uri)
            public
            onlyOwner
            returns (uint256)
        {
            uint256 tokenId = _nextTokenId++;
            _safeMint(to, tokenId);
            _setTokenURI(tokenId, uri);
            return tokenId;
        }

        // The following functions are overrides required by Solidity.

        function _update(address to, uint256 tokenId, address auth)
            internal
            override(ERC721, ERC721Enumerable, ERC721Pausable)
            returns (address)
        {
            return super._update(to, tokenId, auth);
        }

        function _increaseBalance(address account, uint128 value)
            internal
            override(ERC721, ERC721Enumerable)
        {
            super._increaseBalance(account, value);
        }

        function tokenURI(uint256 tokenId)
            public
            view
            override(ERC721, ERC721URIStorage)
            returns (string memory)
        {
            return super.tokenURI(tokenId);
        }

        function supportsInterface(bytes4 interfaceId)
            public
            view
            override(ERC721, ERC721Enumerable, ERC721URIStorage)
            returns (bool)
        {
            return super.supportsInterface(interfaceId);
        }
    }
    ```

    Create a deployment script named `deploy-sei-nft.ts`:

    ```typescript title="scripts/deploy-sei-nft.ts" theme={null}
    import { network } from 'hardhat';

    // Hardhat 3 exposes ethers through a network connection rather than a global import
    const { ethers } = await network.create();

    const [deployer] = await ethers.getSigners();
    console.log('Deploying contracts with the account:', deployer.address);

    // Base URI for your NFT metadata
    const baseURI = 'https://your-metadata-server.com/metadata/';

    const SeiNFT = await ethers.getContractFactory('SeiNFT');
    const seiNFT = await SeiNFT.deploy(deployer.address, baseURI);
    await seiNFT.waitForDeployment();

    console.log('SeiNFT deployed to:', await seiNFT.getAddress());

    // Mint an example NFT
    console.log('Minting an example NFT...');
    const mintTx = await seiNFT.safeMint(deployer.address, '0.json');
    const receipt = await mintTx.wait();
    // _nextTokenId starts at 0, so the first minted token has ID 0.
    // Read the actual ID from the Transfer event:
    const transferEvent = receipt.logs.map((log) => seiNFT.interface.parseLog(log)).find((parsed) => parsed?.name === 'Transfer');
    console.log('NFT minted with ID:', transferEvent?.args.tokenId.toString() ?? 'unknown (no Transfer event in receipt)');
    ```

    Deploy the NFT contract to Sei Testnet:

    ```bash theme={null}
    npx hardhat run scripts/deploy-sei-nft.ts --network seitestnet
    ```
  </Tab>

  <Tab title="Upgradeable UUPS Token">
    With an upgradeable contract, you can change the contract's logic after deployment without changing the contract address. You can use this to fix bugs or add new features. The UUPS (Universal Upgradeable Proxy Standard) pattern is a popular way to implement upgradeability.

    The `@openzeppelin/contracts` package that you installed earlier includes the ERC1967 proxy. For upgradeable contracts, also install the upgradeable variant of the OpenZeppelin library:

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

    <Note>
      OpenZeppelin's `@openzeppelin/hardhat-upgrades` plugin is built for Hardhat 2 and does not register with Hardhat 3's `plugins` array. For this reason, this guide deploys the proxy directly with the ERC1967 standard and upgrades it through the UUPS `upgradeToAndCall` function. This method needs no extra plugin and is fully native to Hardhat 3. The tradeoff is that you do not get the plugin's automatic storage-layout safety checks. Make sure that each new version only **appends** state variables. You can validate layouts separately with [`@openzeppelin/upgrades-core`](https://www.npmjs.com/package/@openzeppelin/upgrades-core).
    </Note>

    You do not need to change `hardhat.config.ts` beyond the configuration shown earlier.

    Next, create the upgradeable ERC20 token in `contracts/UpgradeableSeiToken.sol`:

    ```solidity title="contracts/UpgradeableSeiToken.sol" theme={null}
    // SPDX-License-Identifier: MIT
    pragma solidity ^0.8.22;

    import "@openzeppelin/contracts-upgradeable/token/ERC20/ERC20Upgradeable.sol";
    import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol";
    import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
    import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";

    contract UpgradeableSeiToken is Initializable, ERC20Upgradeable, OwnableUpgradeable, UUPSUpgradeable {
        /// @custom:oz-upgrades-unsafe-allow constructor
        constructor() {
            _disableInitializers();
        }

        function initialize(address initialOwner) initializer public {
            __ERC20_init("Upgradeable Sei Token", "uSEI");
            __Ownable_init(initialOwner);

            // Mint 1 million tokens to the initializer (deployer)
            _mint(initialOwner, 1000000 * 10 ** decimals());
        }

        // Function to mint new tokens (only owner)
        function mint(address to, uint256 amount) virtual public onlyOwner {
            _mint(to, amount);
        }

        // Required for UUPS upgradeability
        function _authorizeUpgrade(address newImplementation)
            internal
            onlyOwner
            override
        {}

        // OPTIONAL: Add a version identifier (useful for tracking upgrades)
        function version() public virtual pure returns (string memory) {
            return "V1";
        }
    }
    ```

    The `Initializable` base, the `initializer` modifier, the `__ERC20_init` and `__Ownable_init` calls, and the `_authorizeUpgrade` override make the contract safe to run behind a proxy. In OpenZeppelin v5, `UUPSUpgradeable` is stateless, so there is no `__UUPSUpgradeable_init` to call.

    Add a thin, named ERC1967 proxy so that Hardhat emits an artifact that you can deploy by name. Create `contracts/SeiTokenProxy.sol`:

    ```solidity title="contracts/SeiTokenProxy.sol" theme={null}
    // SPDX-License-Identifier: MIT
    pragma solidity ^0.8.22;

    import "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";

    contract SeiTokenProxy is ERC1967Proxy {
        constructor(address implementation, bytes memory _data)
            ERC1967Proxy(implementation, _data)
        {}
    }
    ```

    Next, create a deployment script named `scripts/deploy-upgradeable-token.ts`. The script deploys the implementation first. Then it deploys the proxy with the `initialize` call encoded as constructor data, so initialization happens atomically:

    ```typescript title="scripts/deploy-upgradeable-token.ts" theme={null}
    import { network } from 'hardhat';

    const { ethers } = await network.create();

    const [deployer] = await ethers.getSigners();
    console.log('Deploying contracts with the account:', deployer.address);

    // 1. Deploy the implementation contract
    const UpgradeableSeiToken = await ethers.getContractFactory('UpgradeableSeiToken');
    const implementation = await UpgradeableSeiToken.deploy();
    await implementation.waitForDeployment();

    // 2. Deploy the ERC1967 proxy, running initialize(deployer) atomically
    const initData = UpgradeableSeiToken.interface.encodeFunctionData('initialize', [deployer.address]);
    const SeiTokenProxy = await ethers.getContractFactory('SeiTokenProxy');
    const proxy = await SeiTokenProxy.deploy(await implementation.getAddress(), initData);
    await proxy.waitForDeployment();

    // 3. Interact with the token through the proxy address from here on
    const token = await ethers.getContractAt('UpgradeableSeiToken', await proxy.getAddress());
    console.log('Proxy deployed to:', await token.getAddress());
    console.log('Implementation:', await implementation.getAddress());
    console.log('Version:', await token.version()); // V1
    ```

    Deploy the contract to Sei Testnet:

    ```bash theme={null}
    npx hardhat run scripts/deploy-upgradeable-token.ts --network seitestnet
    ```

    **Upgrading the contract**

    To add a new feature or fix a bug, create a new version of the contract, `contracts/UpgradeableSeiTokenV2.sol`:

    ```solidity title="contracts/UpgradeableSeiTokenV2.sol" theme={null}
    // SPDX-License-Identifier: MIT
    pragma solidity ^0.8.22;

    import "./UpgradeableSeiToken.sol"; // Import the V1 contract

    contract UpgradeableSeiTokenV2 is UpgradeableSeiToken {
        // Add a new state variable (ensure it doesn't clash with V1 storage layout)
        uint256 public totalMintedSinceV2;

        // Override the mint function to track new mints
        function mint(address to, uint256 amount) public override onlyOwner {
            super.mint(to, amount);
            totalMintedSinceV2 += amount; // Add V2 logic
        }

        // Override the version function
        function version() public pure override returns (string memory) {
            return "V2";
        }

        // IMPORTANT: V2 does not need its own initializer or constructor for upgrades.
        // The state from V1 is preserved.
    }
    ```

    Next, create an upgrade script named `scripts/upgrade-token.ts`. **Replace `PROXY_ADDRESS` with the proxy address that the deployment script printed.**

    ```typescript title="scripts/upgrade-token.ts" theme={null}
    import { network } from 'hardhat';

    const { ethers } = await network.create();

    // !! REPLACE WITH YOUR PROXY ADDRESS from the deploy step !!
    const PROXY_ADDRESS = '0xYOUR_PROXY_CONTRACT_ADDRESS_HERE';

    const [deployer] = await ethers.getSigners();
    console.log('Upgrading contract with the account:', deployer.address);

    // 1. Deploy the new implementation
    const UpgradeableSeiTokenV2 = await ethers.getContractFactory('UpgradeableSeiTokenV2');
    const v2Implementation = await UpgradeableSeiTokenV2.deploy();
    await v2Implementation.waitForDeployment();

    // 2. Point the proxy at the new implementation (UUPS upgrades are owner-gated)
    const token = await ethers.getContractAt('UpgradeableSeiTokenV2', PROXY_ADDRESS);
    await (await token.upgradeToAndCall(await v2Implementation.getAddress(), '0x')).wait();

    console.log('Upgrade complete. Proxy remains at:', PROXY_ADDRESS);
    console.log('New implementation:', await v2Implementation.getAddress());
    console.log('Contract version:', await token.version()); // V2
    ```

    Run the upgrade script:

    ```bash theme={null}
    npx hardhat run scripts/upgrade-token.ts --network seitestnet
    ```

    You have deployed and upgraded a UUPS contract on Sei with Hardhat and OpenZeppelin.
  </Tab>
</Tabs>

## Testing your smart contracts

Hardhat lets you test your contracts before you deploy them. Create a test file named `test/sei-token-test.ts`:

```typescript title="test/sei-token-test.ts" theme={null}
import { expect } from 'chai';
import { network } from 'hardhat';

describe('SeiToken', function () {
  let ethers;
  let seiToken;
  let owner;
  let addr1;
  let addr2;

  beforeEach(async function () {
    // Hardhat 3 exposes ethers through a network connection
    ({ ethers } = await network.create());

    // Get signers
    [owner, addr1, addr2] = await ethers.getSigners();

    // Deploy the token
    seiToken = await ethers.deployContract('SeiToken', [owner.address]);
  });

  describe('Deployment', function () {
    it('Should set the right owner', async function () {
      expect(await seiToken.owner()).to.equal(owner.address);
    });

    it('Should assign the total supply of tokens to the owner', async function () {
      const ownerBalance = await seiToken.balanceOf(owner.address);
      const totalSupply = await seiToken.totalSupply();
      expect(totalSupply).to.equal(ownerBalance);
    });

    it('Should have correct name and symbol', async function () {
      expect(await seiToken.name()).to.equal('Sei Token');
      expect(await seiToken.symbol()).to.equal('SEI');
    });
  });

  describe('Transactions', function () {
    it('Should transfer tokens between accounts', async function () {
      // Transfer 50 tokens from owner to addr1
      await seiToken.transfer(addr1.address, 50);
      expect(await seiToken.balanceOf(addr1.address)).to.equal(50);

      // Transfer 50 tokens from addr1 to addr2
      await seiToken.connect(addr1).transfer(addr2.address, 50);
      expect(await seiToken.balanceOf(addr2.address)).to.equal(50);
    });

    it("Should fail if sender doesn't have enough tokens", async function () {
      const initialOwnerBalance = await seiToken.balanceOf(owner.address);

      // Try to send 1 token from addr1 (0 tokens) to owner
      await expect(seiToken.connect(addr1).transfer(owner.address, 1)).to.be.reverted;

      // Owner balance shouldn't change
      expect(await seiToken.balanceOf(owner.address)).to.equal(initialOwnerBalance);
    });
  });

  describe('Minting', function () {
    it('Should allow owner to mint new tokens', async function () {
      await seiToken.mint(addr1.address, 1000);
      expect(await seiToken.balanceOf(addr1.address)).to.equal(1000);
    });

    it('Should not allow non-owners to mint', async function () {
      await expect(seiToken.connect(addr1).mint(addr1.address, 1000)).to.be.reverted;
    });
  });
});
```

Run your tests:

```bash theme={null}
npx hardhat test
```

## Deploying to Sei Testnet and Mainnet

After you test your contracts, you can deploy them to Sei Testnet or Sei Mainnet. To deploy, you need:

1. SEI tokens in your wallet for gas
2. Your private key stored in the encrypted keystore (`npx hardhat keystore set SEI_PRIVATE_KEY`), or exported as a `SEI_PRIVATE_KEY` environment variable in CI

Deploy to Sei Testnet:

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

Deploy to Sei Mainnet only when you are ready for production:

```bash theme={null}
npx hardhat ignition deploy ignition/modules/deploy-sei-token.ts --network seimainnet
```


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