A fully compliant ERC20 token implementation built from scratch in Solidity, tested with Brownie and Python. Includes standard token functionality plus minting, burning, and approval race condition protection.
- Overview
- Features
- Project Structure
- Smart Contract
- Getting Started
- Running Tests
- Security Considerations
- Key Concepts
- Roadmap
- Tech Stack
This project implements a secure ERC20 token from scratch without relying on OpenZeppelin or any external libraries. Every function is hand-written and tested to demonstrate a deep understanding of the ERC20 standard, token economics, and Solidity security patterns.
The token supports the full ERC20 interface β transfers, approvals, delegated transfers β plus owner-controlled minting and public burning.
- Full ERC20 compliance β All six standard functions implemented
- Minting β Owner can create new tokens and assign them to any address
- Burning β Any token holder can permanently destroy their own tokens
- Approval race condition protection β Prevents the classic double-spend attack on allowances
- 18 decimal precision β Standard Ethereum token denomination
- Event emission β Transfer and Approval events on every state change
- Owner access control β Mint restricted to the deploying address
erc20-token/
β
βββ contracts/
β βββ MyToken.sol # ERC20 token contract
β
βββ tests/
β βββ test_token.py # Full test suite (23 tests)
β
βββ build/ # Auto-generated compiled artifacts
βββ brownie-config.yaml # Brownie project configuration
| Variable | Type | Description |
|---|---|---|
name |
string |
Full token name (e.g. "My Token") |
symbol |
string |
Short ticker symbol (e.g. "MTK") |
decimals |
uint8 |
Token precision β hardcoded to 18 |
totalSupply |
uint256 |
Total tokens in existence |
owner |
address |
Deployer address β controls minting |
balances |
mapping(address => uint256) |
Token balance per address |
allowances |
mapping(address => mapping(address => uint256)) |
Approved spending limits |
| Event | Parameters | Emitted When |
|---|---|---|
Transfer |
from (indexed), to (indexed), value |
Tokens move between addresses, minted, or burned |
Approval |
owner (indexed), spender (indexed), value |
A spending allowance is set |
Transferfromaddress(0)signals a mint.Transfertoaddress(0)signals a burn. Block explorers like Etherscan use this convention to label transactions correctly.
| Function | Access | Description |
|---|---|---|
transfer(to, value) |
Public | Send your own tokens to another address |
approve(spender, value) |
Public | Authorise another address to spend your tokens |
transferFrom(from, to, value) |
Public | Spend tokens on behalf of an approved owner |
allowance(owner, spender) |
Public view | Check approved spending limit |
mint(to, value) |
Owner only | Create new tokens and assign to an address |
burn(value) |
Public | Destroy your own tokens permanently |
- Python 3.9+
- Node.js 16+
- pip
1. Clone the repository:
git clone https://github.com/YOUR_USERNAME/erc20-token.git
cd erc20-token2. Create and activate a virtual environment:
python -m venv env
source env/bin/activate # macOS/Linux
env\Scripts\activate # Windows3. Install Brownie:
pip install eth-brownie4. Install Ganache:
npm install -g ganache5. Compile the contract:
brownie compilebrownie testVerbose mode β shows each test name individually:
brownie test -v| Test | What It Verifies |
|---|---|
test_token_name |
Name stored correctly on deployment |
test_token_symbol |
Symbol stored correctly |
test_token_decimals |
Decimals hardcoded to 18 |
test_token_total_supply |
Supply calculated with correct decimal conversion |
test_deployer_receives_total_supply |
All tokens sent to deployer on mint |
| Test | What It Verifies |
|---|---|
test_transfer_succeeds |
Valid transfer updates recipient balance |
test_transfer_deducts_sender_balance |
Sender balance decreases correctly |
test_transfer_insufficient_balance |
Underfunded transfer is rejected |
test_transfer_to_zero_address_fails |
Zero address transfers are blocked |
| Test | What It Verifies |
|---|---|
test_approve_sets_allowance |
Allowance recorded correctly |
test_approve_requires_reset_to_zero |
Cannot change allowance without resetting first |
test_approve_reset_to_zero_succeeds |
Allowance can be cleared to zero |
| Test | What It Verifies |
|---|---|
test_transfer_from_succeeds |
Approved spender can move tokens |
test_transfer_from_deducts_allowance |
Allowance reduces after use |
test_transfer_from_exceeds_allowance |
Over-allowance transfer is rejected |
test_transfer_from_without_approval_fails |
Unapproved spender is rejected |
| Test | What It Verifies |
|---|---|
test_owner_can_mint |
Owner can create new tokens |
test_mint_increases_total_supply |
Total supply increases by minted amount |
test_non_owner_cannot_mint |
Non-owners are rejected |
test_mint_to_zero_address_fails |
Cannot mint to zero address |
| Test | What It Verifies |
|---|---|
test_holder_can_burn_tokens |
Token holder can destroy their own tokens |
test_burn_decreases_total_supply |
Total supply decreases by burned amount |
test_cannot_burn_more_than_balance |
Cannot burn more than you hold |
The standard ERC20 approve function has a known vulnerability where a spender can exploit the window between two approval transactions to spend both the old and new allowance.
This contract defends against it by requiring the allowance to be reset to zero before setting a new value:
require(
_value == 0 || allowances[msg.sender][_spender] == 0,
"Reset allowance to 0 first"
);Safe flow:
approve(spender, 100) β allowance = 100
approve(spender, 0) β allowance = 0 β reset first
approve(spender, 50) β allowance = 50 β now safe to set new value
Every state-changing function follows this order:
- Checks β all
requirestatements run first - Effects β state variables are updated
- Interactions β events are emitted last
This order prevents reentrancy attacks by ensuring state is always updated before any external communication occurs.
Built on Solidity ^0.8.0 which includes automatic overflow and underflow protection at the compiler level. No SafeMath library required.
Minting to address(0) is explicitly blocked. Tokens sent to the zero address are permanently unrecoverable β effectively burned without reducing totalSupply, which would corrupt token economics.
Solidity has no floating point numbers. Token amounts are stored as whole integers with an implied decimal point.
1 token with 18 decimals is stored as:
1000000000000000000 (1 followed by 18 zeros)
When you call transfer(recipient, 100 * 10**18)
you are transferring exactly 100 tokens
This two-step pattern allows smart contracts to spend tokens on your behalf β essential for DeFi protocols like Uniswap, Aave, and Compound.
Step 1 β You call approve(uniswap, 100 * 10**18)
"I authorise Uniswap to spend up to 100 of my tokens"
Step 2 β Uniswap calls transferFrom(you, uniswap, 100 * 10**18)
"Uniswap claims the authorised tokens"
| Action | Transfer Event |
|---|---|
| Mint | Transfer(address(0), recipient, amount) |
| Burn | Transfer(holder, address(0), amount) |
The zero address as source or destination is the universal on-chain signal for token creation and destruction.
- Add
increaseAllowanceanddecreaseAllowancehelper functions - Add
transferOwnershipfunction - Implement token pause functionality for emergency stops
- Deploy to Sepolia testnet
- Add a token vesting schedule for gradual release of minted tokens
- Solidity
^0.8.0β Smart contract language - Brownie
1.21.0β Python Ethereum development framework - Ganache β Local Ethereum blockchain
- web3.py β Ethereum Python library
- pytest β Test runner
MIT