Skip to main content

Create Your First ERC-20 Token

Want to understand how USDC, LINK, or UNI work? Build one yourself.

In the next 60-90 minutes, you'll deploy a working ERC-20 token to Ethereum's test network. A real token that works with MetaMask, can be traded on Uniswap, and follows the same standard securing $100+ billion in value. More importantly, you'll learn why developers make specific design choices and how those choices affect security, control, and user experience.

What You'll Learn

You'll understand the architecture behind every ERC-20 token:

  • Why standards eliminate thousands of hours of integration work - Your token automatically works with every compatible wallet and exchange. No partnerships needed.
  • How security audits protect hundreds of millions - OpenZeppelin's contracts secure over $40 billion because thousands of developers found and fixed vulnerabilities over years.
  • Why testing happens in two different programming languages - Solidity tests catch logic errors. TypeScript tests catch integration problems. Professional teams use both.
  • Where control lives in "decentralized" systems - That owner address controlling minting? It's the centralization point regulators care about.

What You Need

RequirementWhy It MattersGet It Here
Node.js & npmRuns the Hardhat development environment and manages code dependenciesnodejs.org
MetaMask walletHolds test funds and signs the deployment transaction that creates your contractmetamask.io
Sepolia test ETHPays for gas fees (~$0 in real value, but required for test network)Google Cloud Faucet
Alchemy RPC URLYour connection point to Ethereum. Like an API key for blockchain access.alchemy.com

Project Setup With Hardhat

Hardhat is a professional development environment that simplifies building on Ethereum. We'll use its interactive initializer to set up our project structure and dependencies.

1. Running Hardhat Init

Open your terminal and run the following command to start the setup process:

npx hardhat init

2. Selecting Version

You'll see the Hardhat welcome screen. Use the latest stable version for access to the newest features and security updates.

 █████  █████                         ███  ███                  ███      ██████
░░███ ░░███ ░███ ░███ ░███ ███░░███
░███ ░███ ██████ ████████ ███████ ░███████ ██████ ███████ ░░░ ░███
░██████████ ░░░░░███░░███░░███ ███░░███ ░███░░███ ░░░░░███░░░███░ ████░
░███░░░░███ ███████ ░███ ░░░ ░███ ░███ ░███ ░███ ███████ ░███ ░░░░███
░███ ░███ ███░░███ ░███ ░███ ░███ ░███ ░███ ███░░███ ░███ ███ ███ ░███
█████ █████░░███████ █████ ░░███████ ████ █████░░███████ ░░█████ ░░██████
░░░░░ ░░░░░ ░░░░░░░ ░░░░░ ░░░░░░░ ░░░░ ░░░░░ ░░░░░░░ ░░░░░ ░░░░░░
👷 Welcome to Hardhat v3.0.7 👷
? Which version of Hardhat would you like to use? …
❯ Hardhat 3 Beta (recommended for new projects)
Hardhat 2 (older version)

3. Folder Selection

Next, Hardhat will ask where to create the project.

Please provide either a relative or an absolute path: › my-stablecoin

4. Type of Project

We will use Viem, a modern and lightweight TypeScript interface for Ethereum that makes interacting with smart contracts intuitive and type-safe.

? What type of project would you like to initialize? … 
❯ A TypeScript Hardhat project using Node Test Runner and Viem
A TypeScript Hardhat project using Mocha and Ethers.js

5. Install Necessary Hardhat Dependencies

The initializer will list all required packages. Confirm the installation to proceed.

You need to install the necessary dependencies using the following command:
npm install --save-dev "hardhat@^3.0.7" "@nomicfoundation/hardhat-toolbox-viem@^5.0.0" "@nomicfoundation/hardhat-ignition@^3.0.0" "@types/node@^22.8.5" "forge-std@foundry-rs/forge-std#v1.9.4" "typescript@~5.8.0" "viem@^2.30.0"
Do you want to run it now? (Y/n) › true

6. Install OpenZeppelin Contracts

To build a secure and compliant ERC-20 token without reinventing the wheel, we'll use the audited OpenZeppelin Contracts library.

npm install @openzeppelin/contracts

Why OpenZeppelin? Building smart contracts from scratch is risky. A tiny mistake can lead to catastrophic financial loss. OpenZeppelin provides modular, reusable, and community-audited smart contracts that follow established standards like ERC-20. Using them substantially reduces the risk of common vulnerabilities.

Understanding What You're About to Build

Before writing code, let's establish what makes this contract a "simplified stablecoin."

Traditional stablecoins like USDC aim to maintain their $1 peg through two mechanisms: centralized minting and redemption. Circle (the company behind USDC) mints new USDC tokens when users deposit dollars and burns tokens when users redeem for dollars. One entity controls the supply tap.

Your token replicates the minting side of this model. The deploying address becomes the only account that can create new tokens. This design pattern appears throughout crypto: Tether with USDT, Binance with BUSD (before its shutdown), and dozens of other fiat-backed stablecoins.

Why build centralized control into a decentralized system? Some problems require it. Stablecoins need mechanisms to respond to market demand. If everyone wants to buy USDC, Circle needs to mint more. If everyone's redeeming, they burn supply. Pure algorithmic alternatives (Terra/UST, remember?) failed catastrophically because they lacked this direct control.

The trade-off is obvious: users trust the owner won't abuse minting privileges. Regulatory compliance helps here. Circle submits to audits and regulatory oversight. Your test token? It demonstrates the technical mechanism, not the trust infrastructure.

Now let's implement this pattern in code.

Smart Contract Development

We'll define our token's logic by inheriting from two core OpenZeppelin contracts: ERC20 (for the standard token functionality) and Ownable (for administrative control).

1. Create the Contract File

First, create a new Solidity file in the contracts folder.

touch contracts/MyStablecoin.sol

2. Initial Imports and Inheritance

We start by defining the Solidity compiler version, setting the license, and importing the necessary OpenZeppelin contracts. Our contract, MyStablecoin, will inherit all the functionality from both ERC20 and Ownable.

// SPDX-License-Identifier: MIT
pragma solidity 0.8.28;

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

contract MyStablecoin is ERC20, Ownable {
constructor() {}
}

3. Implementing the Constructor

The constructor runs once when the contract is deployed. We use it to set the token's initial state.

solidity

/**
* @notice Constructor initializes the token and mints the initial supply to the deployer.
* @dev The input initialSupply is the raw, non-decimal-adjusted amount in the token's smallest unit.
* @param initialSupply The total supply in the smallest unit (e.g., 1000000000000000000000000 for 1 million tokens).
* 1000000000000000000000000 - 1000000 * 10**18 - 18 decimals is default standard in ERC20 token
*/
constructor(uint256 initialSupply)
// 1. Call the ERC20 constructor to set Name and Symbol
ERC20("My Stablecoin", "MS")
// 2. Call the Ownable constructor to set the contract deployer as the initial owner
Ownable(msg.sender)
{
_mint(msg.sender, initialSupply);
}
  • ERC20("My Stablecoin", "MS"): We initialize the underlying ERC20 contract, setting the public name and symbol for our token.
  • Ownable(msg.sender): We initialize the Ownable contract, designating msg.sender (the address deploying the contract) as the owner.
  • _mint(msg.sender, initialSupply): We call the internal _mint function from the ERC20 contract to create the initial supply of tokens and assign them to the owner's address. Note that amounts are handled in their smallest unit (like cents to a dollar). With 18 decimals, 1 token is represented as 1 * 10^18.

4. Implementing Mint Functionality

This function exposes the internal _mint capability, but with a restriction: only the owner can call it. This is the core of our "stablecoin" model, allowing the owner to increase the total supply at will.

/**
* @notice Allows the owner to issue new tokens.
* @dev Input amount is expected to be in the smallest unit.
* @param to The address that will receive the minted tokens.
* @param amount The number of tokens to mint, given in the smallest unit.
*/
function mint(address to, uint256 amount) external onlyOwner {
_mint(to, amount);
}

The onlyOwner modifier, inherited from Ownable, automatically adds a check to ensure that msg.sender is the contract's owner. If anyone else tries to call this function, the transaction will fail.

5. Implementing Burn Functionality

To complete our supply management, we'll add a burn function. This allows any user to permanently destroy their own tokens, removing them from circulation and decreasing the total supply.

/**
* @notice Allows any token holder to permanently remove tokens from the supply.
* @dev Input amount is expected to be in the smallest unit.
* @param amount The number of tokens to burn, given in the smallest unit.
*/
function burn(uint256 amount) external {
_burn(msg.sender, amount);
}

The internal _burn(msg.sender, amount) function from OpenZeppelin's ERC20 contract handles the logic securely, ensuring a user can only burn tokens they possess.

Why Testing Isn't Optional

Your contract is 50 lines of code. Simple? Not when it controls value.

Those 50 lines control value. Maybe just testnet tokens worth nothing today. But the same patterns secure USDC's $25 billion, LINK's oracle network feeding data to $14 billion in DeFi, and countless other tokens. One logic error means lost funds. No customer service to call. No transactions to reverse. Just a permanent bug in immutable code.

Smart contract testing differs from traditional software testing in one way: the cost of bugs. A bug in a web app might crash the page. A bug in a smart contract might drain millions before anyone notices. The Poly Network hack extracted $600 million through a tiny authorization flaw. The DAO hack stole $60 million from what everyone assumed was carefully reviewed code.

This is why professional developers test obsessively and why we're using two completely different testing approaches:

Solidity tests via Foundry run directly on the EVM (Ethereum Virtual Machine). They execute fast—thousands of tests per second—and let you test edge cases cheaply. Can a user burn more tokens than they hold? Does the minting function properly reject non-owners? Solidity tests answer these questions by checking individual functions in isolation.

TypeScript tests via Viem simulate real-world usage from start to finish. They connect to a local blockchain fork, deploy your contract using actual wallet clients, and execute multi-step interactions just like a user would. This catches integration issues: Does the deployment work? Can different accounts interact correctly? Do events emit properly?

You need both. Unit tests (Solidity) catch logic errors in functions. Integration tests (TypeScript) catch workflow problems between functions.

Let's write tests that prove your contract works.

Writing Tests

Testing is not optional in smart contract development; it's essential. Hardhat v3 integrates Foundry, allowing us to write tests in both Solidity (for fast, low-level unit tests) and TypeScript (for end-to-end integration tests).

Solidity Unit Tests

We'll use Foundry-style Solidity tests to check individual functions in isolation. This verifies specific logic and edge cases.

1. Setup

touch contracts/MyStablecoin.t.sol

Next, add the boilerplate. The setUp function runs before each test, deploying a fresh instance of our contract to ensure tests don't interfere with each other.

// SPDX-License-Identifier: MIT
pragma solidity 0.8.28;

import {MyStablecoin} from "./MyStablecoin.sol";
import {Test} from "forge-std/Test.sol";
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";

contract MyStablecoinTest is Test {
MyStablecoin stablecoin;
uint256 public constant INITIAL_SUPPLY = 1_000_000 * 10 ** 18;

// The address of the contract deployer/owner for tests.
address owner = address(this);

function setUp() public {
// Deploy the contract with an initial supply of 1 million tokens.
stablecoin = new MyStablecoin(INITIAL_SUPPLY);
}
}

2. Test Constructor

This test verifies that the contract is deployed with the correct initial state values: name, symbol, decimals, owner, and total supply.

/**
* @notice Tests if the contract is deployed with the correct initial state.
*/
function test_ConstructorState() public {
assertEq(stablecoin.name(), "My Stablecoin", "Incorrect token name");
assertEq(stablecoin.symbol(), "MS", "Incorrect token symbol");
assertEq(stablecoin.decimals(), 18, "Incorrect decimals");
assertEq(stablecoin.owner(), owner, "Incorrect owner");
assertEq(stablecoin.totalSupply(), INITIAL_SUPPLY, "Incorrect total supply");
assertEq(stablecoin.balanceOf(owner), INITIAL_SUPPLY, "Owner balance is not the initial supply");
}

3. Test Mint

Here, we test both the "happy path" (successful minting by the owner) and the "sad path" (failed attempt by a non-owner).

/**
* @notice Tests if the owner can successfully mint new tokens.
*/
function test_Mint_Success() public {
address recipient = address(0xABC);
uint256 amountToMint = 500_000 * 10 ** 18;

// Pre-mint state checks
uint256 initialTotalSupply = stablecoin.totalSupply();
uint256 initialRecipientBalance = stablecoin.balanceOf(recipient);
assertEq(initialRecipientBalance, 0);

// Mint new tokens
stablecoin.mint(recipient, amountToMint);

// Post-mint state checks
assertEq(stablecoin.totalSupply(), initialTotalSupply + amountToMint, "Total supply did not increase correctly");
assertEq(stablecoin.balanceOf(recipient), initialRecipientBalance + amountToMint, "Recipient balance did not update correctly");
}

/**
* @notice Tests that minting fails if called by an account other than the owner.
*/
function testFail_Mint_NotOwner() public {
address notOwner = address(0x123);
uint256 amount = 100 * 10 ** 18;

// Set the next call's sender to be the non-owner address
vm.prank(notOwner);
// Expect the transaction to revert with Ownable's specific error
vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, notOwner));

// Attempt to mint from the non-owner account
stablecoin.mint(notOwner, amount);
}
  • vm.prank(notOwner): This is a "cheat code" from Foundry that lets us simulate a transaction coming from any address we want.
  • vm.expectRevert(...): This tells the test runner to expect the next call to fail with a specific error message, ensuring our onlyOwner modifier works correctly.

4. Test Burn

We test for successful burning and for the case where a user tries to burn more tokens than they have.

/**
* @notice Tests if a token holder can successfully burn their tokens.
*/
function test_Burn_Success() public {
uint256 amountToBurn = 250_000 * 10 ** 18;

// Pre-burn state checks
uint256 initialTotalSupply = stablecoin.totalSupply();
uint256 initialOwnerBalance = stablecoin.balanceOf(owner);
assertTrue(initialOwnerBalance >= amountToBurn);

// Burn tokens
stablecoin.burn(amountToBurn);

// Post-burn state checks
assertEq(stablecoin.totalSupply(), initialTotalSupply - amountToBurn, "Total supply did not decrease correctly");
assertEq(stablecoin.balanceOf(owner), initialOwnerBalance - amountToBurn, "Burner's balance did not update correctly");
}

/**
* @notice Tests that burning fails if the user's balance is insufficient.
*/
function testFail_Burn_InsufficientBalance() public {
address userWithNoTokens = address(0x456);
uint256 amountToBurn = 1 * 10 ** 18;

// Sanity check: user has no tokens
assertEq(stablecoin.balanceOf(userWithNoTokens), 0);

// Set the next call's sender
vm.prank(userWithNoTokens);

// Expect revert with ERC20's insufficient balance error
// ERC20InsufficientBalance(address sender, uint256 balance, uint256 needed)
bytes4 selector = bytes4(keccak256("ERC20InsufficientBalance(address,uint256,uint256)"));
vm.expectRevert(abi.encodeWithSelector(selector, userWithNoTokens, 0, amountToBurn));

// Attempt to burn
stablecoin.burn(amountToBurn);
}

What These Tests Verify

Look at what we've built. Five test functions that check specific behaviors:

Constructor test verifies the initial state matches expectations. If this fails, something broke during deployment—wrong name, wrong supply, wrong owner. These are the values the entire token economy depends on.

Mint success test confirms the happy path: owner mints tokens, recipient receives them, total supply increases correctly. This is the core functionality enabling supply management.

Mint failure test proves the security mechanism works: non-owners can't create tokens from thin air. This single test stands between controlled supply and infinite inflation.

Burn success test validates supply reduction. When someone burns tokens, both their balance and total supply decrease by exactly the same amount. No tokens disappearing into the void, no accounting errors.

Burn failure test ensures users can't burn tokens they don't have. Sounds obvious, but without this check, a user could trigger integer underflow and accidentally give themselves billions of tokens.

Each test follows the same pattern: set up initial state, execute action, verify results match expectations. This is how professional teams build confidence in their code before risking real value.

5. Run Solidity Tests

Execute the tests using the following command. You should see all tests passing.

npx hardhat test solidity contracts/MyStablecoin.t.sol

TypeScript End-to-End Test

While Solidity tests work well for unit logic, TypeScript tests simulate real-world user interactions from deployment to multiple function calls.

1. Setup

Create the TypeScript test file:

touch test/MyStablecoin.ts

And add the initial boilerplate to connect to the Hardhat network.

import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { network } from "hardhat";

describe("MyStablecoin", async function () {
const { viem } = await network.connect();
const publicClient = await viem.getPublicClient();
});

2. Implement E2E Flow

This single test will cover the entire lifecycle: deploy the contract, verify its initial state, have the owner mint tokens to another account, verify the new state, have the owner burn some of their tokens, and verify the final state. This ensures all parts of the contract work together as expected.

typescript

import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { network } from "hardhat";

describe("MyStablecoin", async function () {
// Establish connection to the Hardhat network and get viem clients
const { viem } = await network.connect();
const publicClient = await viem.getPublicClient();

// Define constants for the test using BigInt for precision
const INITIAL_SUPPLY = 1_000_000n * 10n ** 18n;

await it("should allow the owner to mint and any user to burn tokens in an end-to-end flow", async function () {
// 1. SETUP: Get accounts (wallets) for the test
const [owner, recipient] = await viem.getWalletClients();
const amountToMint = 500_000n * 10n ** 18n;
const amountToBurn = 200_000n * 10n ** 18n;

// 2. DEPLOYMENT: Deploy the MyStablecoin contract with the initial supply
// The contract instance is automatically connected to the 'owner' wallet client
const contract = await viem.deployContract("MyStablecoin", [INITIAL_SUPPLY], {
client: {wallet: owner},
});
console.log(`Contract deployed at: ${contract.address}`);

// 3. VERIFY INITIAL STATE
console.log("Verifying initial state...");
assert.equal(
await contract.read.totalSupply(),
INITIAL_SUPPLY,
"Initial total supply is incorrect"
);
assert.equal(
await contract.read.balanceOf([owner.account.address]),
INITIAL_SUPPLY,
"Owner's initial balance is incorrect"
);

// 4. MINT NEW TOKENS
console.log(`Minting ${amountToMint / 10n ** 18n} tokens to ${recipient.account.address}...`);
const mintTxHash = await contract.write.mint([recipient.account.address, amountToMint]);
await publicClient.waitForTransactionReceipt({hash: mintTxHash});

// 5. VERIFY STATE AFTER MINT
console.log("Verifying state after mint...");
const expectedSupplyAfterMint = INITIAL_SUPPLY + amountToMint;
assert.equal(
await contract.read.totalSupply(),
expectedSupplyAfterMint,
"Total supply did not increase correctly after minting"
);
assert.equal(
await contract.read.balanceOf([recipient.account.address]),
amountToMint,
"Recipient's balance is incorrect after minting"
);

// 6. BURN TOKENS
// The owner burns a portion of their own tokens
console.log(`Owner burning ${amountToBurn / 10n ** 18n} tokens...`);
const burnTxHash = await contract.write.burn([amountToBurn]);
await publicClient.waitForTransactionReceipt({hash: burnTxHash});

// 7. VERIFY FINAL STATE AFTER BURN
console.log("Verifying final state after burn...");
const expectedFinalSupply = expectedSupplyAfterMint - amountToBurn;
const expectedFinalOwnerBalance = INITIAL_SUPPLY - amountToBurn;

assert.equal(
await contract.read.totalSupply(),
expectedFinalSupply,
"Total supply did not decrease correctly after burning"
);
assert.equal(
await contract.read.balanceOf([owner.account.address]),
expectedFinalOwnerBalance,
"Owner's balance is incorrect after burning"
);

console.log("✅ End-to-end test completed successfully!");
});
});

3. Run TypeScript Tests

Now, run the end-to-end test.

npx hardhat test nodejs test/MyStablecoin.ts

From Test Network to Public Blockchain

Everything you've built so far runs on your local computer. Hardhat creates a simulated blockchain that resets every time you restart. It's perfect for testing but insufficient for anyone else to use. Now we're changing that.

Deploying to Sepolia (Ethereum's test network) means your contract lives on a blockchain that thousands of nodes maintain. It becomes permanent. It becomes public. It becomes real—or as real as a test network gets.

This is where theory meets practice. You're about to interact with a public blockchain network, broadcast a transaction that thousands of computers will validate, and create a permanent record that anyone with internet access can verify. The process mirrors exactly what you'd do on Ethereum mainnet, except Sepolia ETH is free and mistakes cost nothing.

Three things need to happen:

1. Your code needs to connect to the Ethereum network - That's what the Alchemy RPC URL provides. Think of it as your gateway to blockchain infrastructure. Without it, your computer can't talk to Ethereum nodes.

2. You need to prove you're authorizing the deployment - That's what your private key does. It cryptographically signs the transaction creating your contract, proving you have the authority to spend the gas fees.

3. The deployment needs to be repeatable and verifiable - That's what Hardhat Ignition provides. It creates deployment artifacts that prove exactly what code was deployed with what parameters.

Before we proceed: a serious note about security.

Setting Up for Deployment

With our contract written and thoroughly tested, it's time to deploy it to a public network. We'll use the Sepolia testnet.

1. Setting Up Environment Variables

To deploy, you need to provide your wallet's private key (to sign the transaction) and an RPC URL (to communicate with the network). Storing these directly in your code is a major security risk. Hardhat provides a secure keystore to manage these secrets.

Set SEPOLIA_RPC_URL: Run the command below, set a password for your keystore, and paste in your Alchemy RPC URL.

npx hardhat keystore set SEPOLIA_RPC_URL

👷🔐 Hardhat Production Keystore 🔐👷

This is the first time you are using the production keystore, please set a password.
The password must have at least 8 characters.
[hardhat-keystore] Enter the password: ********
[hardhat-keystore] Please confirm your password: ********
[hardhat-keystore] Enter secret to store in the production keystore: **********************************************************
Key "SEPOLIA_RPC_URL" set in the production keystore

Set SEPOLIA_PRIVATE_KEY: Do the same for your wallet's private key. WARNING: Your private key controls your funds. Never share it with anyone or commit it to a public repository.

npx hardhat keystore set SEPOLIA_PRIVATE_KEY
[hardhat-keystore] Enter the password: ********
[hardhat-keystore] Enter secret to store in the production keystore: ****************************************************************
Key "SEPOLIA_PRIVATE_KEY" set in the production keystore

2. Implementing the Deployment Script

We will use hardhat-ignition, a powerful and declarative deployment system that makes deployments reliable and repeatable.

Create the deployment file:

touch ignition/modules/MyStablecoin.ts

Setup the deployment script: This script tells Ignition what contract to deploy (MyStablecoin) and what arguments to pass to its constructor (the initialSupply).

import { buildModule } from "@nomicfoundation/hardhat-ignition/modules";

export default buildModule("MyStablecoinModule", (m) => {
const initialSupply = 1000000n * 10n ** 18n; // Supply of 1 Million
const myStablecoin = m.contract("MyStablecoin", [initialSupply]);

return { myStablecoin };
});

3. Deploy Your Stablecoin!

Run the deployment command, targeting the Sepolia network. You will be prompted for your keystore password.

npx hardhat ignition deploy ignition/modules/MyStablecoin.ts --network sepolia

After confirming, Ignition will deploy your contract. If successful, you'll see the contract address printed in your terminal.

npx hardhat ignition deploy ignition/modules/MyStablecoin.ts --network sepolia
[hardhat-keystore] Enter the password: ********
✔ Confirm deploy to network sepolia (11155111)? … yes
Hardhat Ignition 🚀

Deploying [ MyStablecoinModule ]

Batch #1
Executed MyStablecoinModule#MyStablecoin

[ MyStablecoinModule ] successfully deployed 🚀

Deployed Addresses

MyStablecoinModule#MyStablecoin - 0x5d7a1B66613F5285aaE1B0149F30aa44DE80279a

Congratulations! You have successfully deployed your own ERC-20 token to the Ethereum Sepolia testnet.

Conclusion

You've successfully journeyed from an empty folder to a live ERC-20 token on a public testnet. By following this guide, you have:

  1. Set up a professional development environment using Hardhat.
  2. Written a secure ERC-20 smart contract by inheriting from OpenZeppelin's audited base contracts.
  3. Implemented custom logic for a simplified stablecoin with mint and burn functionalities controlled by an owner.
  4. Mastered a dual-testing strategy, writing both granular unit tests in Solidity and comprehensive end-to-end tests in TypeScript.
  5. Securely managed secrets using Hardhat's keystore and deployed your contract to the Sepolia testnet using Hardhat Ignition.