Documentation Index
Fetch the complete documentation index at: https://seilabs-docs-evm-fixes-hardhat-v3.mintlify.app/llms.txt
Use this file to discover all available pages before exploring further.
This tutorial will guide you through setting up Hardhat for Sei EVM development and using OpenZeppelin contracts to build secure, standardized smart contracts. We’ll cover environment setup, contract creation, deployment, and show how to leverage OpenZeppelin’s pre-built components.
Deploy to Testnet FirstIt is highly recommended that you deploy to testnet (atlantic-2) first and verify everything works as expected before committing to mainnet. Doing so helps you catch bugs early, avoid unnecessary gas costs, and keep your users safe.
Table of Contents
Prerequisites
Before we begin, ensure you have the following installed:
- Node.js (v18.0.0 or later)
- npm (v7.0.0 or later) or yarn
- A code editor (VS Code recommended)
Setting Up Your Development Environment
Let’s create a new project and set up Hardhat:
# 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:
npm install @openzeppelin/contracts
Prefer a non-interactive setup (CI or scripted environments)? Do the same thing by hand — no prompts: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 pulls in ethers, Ignition, Mocha/Chai, and the keystore, network-helpers, verify, and typechain plugins — so this single install covers everything in this guide. If you hit peer-dependency errors, re-run with --legacy-peer-deps.
Configuring Hardhat for Sei EVM
Next, we’ll need to configure Hardhat to work with the Sei EVM. Update your hardhat.config.ts file:
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 (atlantic-2)
seitestnet: {
type: 'http',
chainType: 'l1',
url: 'https://evm-rpc-testnet.sei-apis.com',
accounts: [configVariable('SEI_PRIVATE_KEY')],
chainId: 1328
},
// Sei mainnet (pacific-1)
seimainnet: {
type: 'http',
chainType: 'l1',
url: 'https://evm-rpc.sei-apis.com',
accounts: [configVariable('SEI_PRIVATE_KEY')],
chainId: 1329
}
}
};
export default config;
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', and plugins load via the plugins array rather than the Hardhat 2 side-effect import '@nomicfoundation/hardhat-toolbox'. A local simulated network is provided automatically — no hardhat/localhost entry required.
Hardhat 3 reads secrets through configVariable(...), backed by an encrypted keystore — so your key is never stored in a plaintext file. Set it once:
npx hardhat keystore set SEI_PRIVATE_KEY
You’ll be prompted to paste the private key of the account you deploy from; Hardhat stores it encrypted on your machine. For CI, export a SEI_PRIVATE_KEY environment variable instead — configVariable falls back to it.
Use a dedicated, throwaway deploy key funded with only what you need — never a personal wallet holding real funds.
Using OpenZeppelin Contracts
OpenZeppelin provides a library of secure, tested smart contract components that you can use to build your applications. First, let’s install the OpenZeppelin Contracts package:
npm install @openzeppelin/contracts
Creating and Deploying an ERC20 Token, ERC721 NFT or an Upgradeable UUPS Token
ERC20
ERC721
Upgradeable UUPS Token
Let’s create a simple ERC20 token using OpenZeppelin contracts. Create a new file in the contracts directory called SeiToken.sol:// 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);
}
}
Now, create a deployment script in the ignition/modules directory called deploy-sei-token.ts:ignition/modules/deploy-sei-token.ts
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 the Sei testnet:npx hardhat ignition deploy ignition/modules/deploy-sei-token.ts --network seitestnet
Now, let’s create an ERC721 NFT contract. Create a new file SeiNFT.sol in the contracts directory:// 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 deploy-sei-nft.ts:scripts/deploy-sei-nft.ts
import { network } from 'hardhat';
// Hardhat 3 exposes ethers through a network connection rather than a global import
const { ethers } = await network.getOrCreate();
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, '1.json');
await mintTx.wait();
console.log('NFT minted with ID: 1');
Deploy the NFT contract to the Sei testnet:npx hardhat run scripts/deploy-sei-nft.ts --network seitestnet
Upgradeable contracts allow you to modify the contract’s logic after deployment without changing the contract address, which is crucial for fixing bugs or adding new features. The UUPS (Universal Upgradeable Proxy Standard) pattern is a popular way to implement upgradeability.For upgradeable contracts, also install the upgradeable variant of the OpenZeppelin library (the @openzeppelin/contracts package you installed earlier supplies the ERC1967 proxy):npm install @openzeppelin/contracts-upgradeable
OpenZeppelin’s @openzeppelin/hardhat-upgrades plugin is built for Hardhat 2 and does not register with Hardhat 3’s plugins array. So this guide deploys the proxy directly using the ERC1967 standard and upgrades through UUPS upgradeToAndCall — no extra plugin, fully Hardhat 3-native. The tradeoff: you don’t get the plugin’s automatic storage-layout safety checks, so make sure each new version only appends state variables. You can validate layouts separately with @openzeppelin/upgrades-core. No hardhat.config.ts changes are needed beyond the configuration shown earlier.Now, let’s create an upgradeable ERC20 token. Create contracts/UpgradeableSeiToken.sol:contracts/UpgradeableSeiToken.sol
// 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";
}
}
Note the Initializable base, the initializer modifier, the __ERC20_init / __Ownable_init calls, and the _authorizeUpgrade override — these are what make the contract safe to run behind a proxy. (OpenZeppelin v5’s UUPSUpgradeable is stateless, so there is no __UUPSUpgradeable_init to call.)Add a thin, named ERC1967 proxy so Hardhat emits an artifact you can deploy by name. Create contracts/SeiTokenProxy.sol:contracts/SeiTokenProxy.sol
// 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)
{}
}
Now create a deployment script scripts/deploy-upgradeable-token.ts. It deploys the implementation, then deploys the proxy with the initialize call encoded as constructor data so initialization happens atomically:scripts/deploy-upgradeable-token.ts
import { network } from 'hardhat';
const { ethers } = await network.getOrCreate();
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 this to the testnet:npx hardhat run scripts/deploy-upgradeable-token.ts --network seitestnet
Upgrading the ContractLet’s say you want to add a new feature or fix a bug. Create a new version of the contract, contracts/UpgradeableSeiTokenV2.sol:contracts/UpgradeableSeiTokenV2.sol
// 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.
}
Now, create an upgrade script scripts/upgrade-token.ts. Replace PROXY_ADDRESS with the address printed when you deployed the proxy.import { network } from 'hardhat';
const { ethers } = await network.getOrCreate();
// !! 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:npx hardhat run scripts/upgrade-token.ts --network seitestnet
You have now successfully deployed and upgraded a UUPS contract on the Sei network using Hardhat and OpenZeppelin!
Testing Your Smart Contracts
Hardhat makes it easy to test your contracts before deploying them. Create a test file test/sei-token-test.ts:
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.getOrCreate());
// 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 with:
Deploying to Sei Testnet and Mainnet
Once you’ve tested your contracts, you can deploy them to the Sei testnet or mainnet. To deploy, you’ll need:
- SEI tokens in your wallet for gas fees
- Your private key in the
.env file
Deploy to the testnet:
npx hardhat ignition deploy ignition/modules/deploy-sei-token.ts --network seitestnet
Deploy to the mainnet (only when you’re ready for production):
npx hardhat ignition deploy ignition/modules/deploy-sei-token.ts --network seimainnet