Building Your First Token: A Solidity Speedrun

Table of contents
- Setting Up Your First Smart Contract
- The Token Ledger: Who Owns What?
- Creating Tokens
- Events in Your Smart Contract
- The Transfer Function
- The Token Approval Mechanism
- Making It Standard: The IERC20 Interface
- Testing Your Token
- Storage and Gas Optimization
- Common Pitfalls and Security Considerations
- What You've Accomplished
- What Next?
- Resources for Continued Learning

Every developer usually starts with a simple program: “Hello, World!” It’s that first step where you test if everything’s working. It doesn’t do much, but it proves your setup is good and your code can run. In blockchain, “Hello World” looks a little different. It’s not just printing a message. It’s writing and deploying your first token contract.
If you've written JavaScript or Python, you already have the mental models needed for Solidity. The syntax will feel familiar, but the environment is completely different. Every line of code incurs a cost to execute. Every variable is permanently stored on thousands of computers worldwide. And once deployed, your code is immutable - bugs become features forever.
It might sound intimidating at first, but once you understand the fundamentals, it becomes much more approachable.
By the end of this tutorial, you'll understand how to write a token contract and why each piece works the way it does. We'll build a simplified ERC20 token that could be deployed on Ethereum, Monad, or any EVM-compatible chain.
Setting Up Your First Smart Contract
A smart contract is like a vending machine; it holds value, has rules, and executes automatically. The contract we're building today will create and manage tokens that could represent anything: loyalty points at your local coffee shop, shares in an investment fund, or coins in a game economy.
Before we can build this digital vending machine, we need a workspace. That's where Remix IDE comes in; think of it as your contract workshop.
We'll use Remix IDE, a powerful, web-based development environment for Solidity, to keep things simple and get you writing code right away. There's no need to install anything; open it in a new tab, and you'll be ready.
Once Remix loads:
Look for the contracts folder in the file explorer (left sidebar).
Right-click the file icon and create a new file called MyToken.sol.
Click on your new file to open it.
Next, create the basic structure of your token.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract MyToken {
// Token Implementation here!
}
That might look like a lot at first glance, but let’s break it down step-by-step:
// SPDX-License-Identifier: MIT
: This comment specifies the contract's license. Including the comment is a best practice and helps build trust in the open-source community.pragma solidity ^0.8.20;
: This line tells the compiler which version of Solidity to use. The ^ means it will accept version 0.8.20 or any newer minor version (like 0.8.21) but not major updates that could break compatibility (like 0.9.0).contract MyToken { ... }
: This declares our contract. If you come from an object-oriented language like Python or JavaScript, you can think of a contract as a class. It's a blueprint for the logic and data that will live on the blockchain.
Giving Your Token an Identity
Now, let's define what makes our token unique. Just like how the US Dollar has a name ("Dollar"), symbol ("$"), and can be divided into cents, our token needs these fundamental properties:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract MyToken {
// Token metadata - this is what makes your token unique
string public name = "MyToken";
string public symbol = "MTK";
uint8 public decimals = 18; // Standard for most tokens
uint256 public totalSupply; // How many tokens exist in total
}
In the code snippet above, we've just introduced a few core concepts:
State Variables: These variables (name, symbol, decimals, totalSupply) are stored permanently on the blockchain. Think of them as the properties of a class instance.
Data Types: We're using string for text, uint8 for an 8-bit unsigned integer (perfect for decimals), and uint256for a 256-bit unsigned integer, the standard for token amounts.
Visibility (public): Marking a state variable as public has a significant impact. The Solidity compiler automatically creates a "getter" function for it. This means anyone can read its value from the blockchain for free. This is our first look at a view-only function.
Now we have a token with an identity, but we're missing the most important part - a way to track who owns what. Let's build the ledger that makes this a real token.
The Token Ledger: Who Owns What?
At its core, a token contract has one fundamental job: answering the question, "How many tokens does this address have?" To keep track of this, we need a ledger. In traditional systems, a bank’s database manages account balances. In Solidity, the ideal tool for this job is a mapping.
//...
contract MyToken {
// ... inside contract MyToken
// A mapping from an address to their token balance.
mapping(address => uint256) public balances;
}
A mapping in Solidity is like a hash map or dictionary you might know from other programming languages. This particular mapping, called balances, links an address to a uint256 value. Think of it as a super-efficient dictionary: you give it an address, and it instantly tells you the token balance for that address. No searching or sorting needed; just instant lookups.
address
: This is a special data type that holds a 20-byte Ethereum address. It's the unique identifier for a user's wallet or another smart contract.balances[someAddress]
: When you query this, it returns the token balance for someAddress. If that address has never received tokens before, the balance defaults to 0.
For example:
balances[0x123...] = 1000; // This address has 1000 tokens
balances[0x456...] = 500; // This one has 500
Since we marked the balances mapping as public, we automatically get a getter function. We can now create a more explicit balanceOf function that matches the ERC20 standard.
// ... inside contract MyToken
function balanceOf(address account) public view returns (uint256) {
return balances[account];
}
Notice the view keyword. This explicitly tells the Ethereum Virtual Machine (EVM) that this function only reads from the blockchain state and doesn't modify it. Calling view functions is free from the outside world because they don't require a transaction to be mined.
Perfect! We can track balances, but everyone's balance is zero. Time to create some tokens.
Creating Tokens
Currently, our token exists, but nobody currently holds any. It's like opening a bank with no money. Let's fix that by creating a minting function that can allow users to mint tokens:
//...
contract MyToken {
// Previous code...
// The address that deployed this contract (the creator)
address public owner;
// This special function runs ONCE when the contract is deployed
constructor() {
owner = msg.sender; // Crown yourself as the owner!
}
function mint(address to, uint256 amount) public {
// Only the owner can create new tokens
require(msg.sender == owner, "Nice try! Only the owner can mint.");
totalSupply += amount; // Increase total supply
balances[to] += amount; // Give tokens to the recipient
}
}
Whoa, a lot just happened. Let's unpack it.
msg.sender
is a global variable available in every function. It always contains the address of whoever called this function. It's unfakeable, unhackable, and always reliable, and it's how your contract identifies who is talking to it. Think of it as a caller ID that works.
In our constructor, we're using it to remember who deployed the contract. That person becomes the owner, the only one allowed to mint new tokens.
As you explore Solidity, you'll encounter tx.origin, a variable that seems helpful but hides a dangerous flaw. Let's explore why msg.sender
is always the right choice:
// Dont do this
require(tx.origin == owner, "Only owner");
// Do this instead
require(msg.sender == owner, "Only owner");
For example, using tx.origin
for authorization is dangerous because it can make your contract vulnerable to phishing-style attacks. If User A calls MaliciousContract
B, which then calls YourContract C
, msg.sender
in C will be B's address, but tx.origin
will be User A's address.
If you authorize based on tx.origin
, MaliciousContract
B could trick User A into triggering an action on your contract that they never intended. Always use msg.sender for authorization.
Now our token can be minted, but there’s still a problem. How does the outside world know when tokens are created?
This touches on a core blockchain principle: transparency through observation. Unlike traditional systems where a server pushes updates to clients, blockchain works on a "pull" model.
Your wallet, Etherscan, and every DeFi protocol must actively watch for changes. Without a standardized way to signal "something important happened here," they'd have to scan every single storage change - an impossibly expensive and slow process.
Events solve this by creating an efficient notification layer on top of the blockchain.
Events in Your Smart Contract
Events are the backbone of blockchain visibility. Block explorers use them to show transaction history, wallets listen for them to update your balance, and DeFi protocols track them for analytics. Plus, they're 8x cheaper than storing data.
To implement events in your contract, you declare the event, which is similar to defining a notification template, and then trigger it after your preferred function call or action, as shown in the following code.
//...
contract MyToken {
// Previous code...
// Declare an event
event Transfer(address indexed from, address indexed to, uint256 value);
function mint(address to, uint256 amount) public {
require(msg.sender == owner, "Only owner can mint");
totalSupply += amount;
balances[to] += amount;
// Fire the event! Tell the world tokens were created
emit Transfer(address(0), to, amount);
}
}
When minting, we show tokens from address(0). This is the universal signal for "new tokens created."
Minting is great, but tokens that can't move aren't very useful. Let's add the ability to transfer tokens between addresses.
The Transfer Function
Create the transfer function named transfer with the following code snippet:
//...
function transfer(address to, uint256 amount) public returns (bool) {
// Check 1: Do you have enough tokens?
require(balances[msg.sender] >= amount, "Not enough tokens!");
// Check 2: Are you sending to a real address?
require(to != address(0), "Can't send to the zero address");
// The actual transfer (order matters for security!)
balances[msg.sender] -= amount; // Take from sender
balances[to] += amount; // Give to recipient
// Announce it to the world
emit Transfer(msg.sender, to, amount);
return true; // ERC20 standard says return true on success
}
The code snippet above does the following:
First, we checked if you're not trying to send more tokens than you have (no overdrafts in blockchain).
Next, we made sure you're not accidentally burning tokens by sending to the zero address.
Then we performed the actual transfer to subtract from the sender and add to the recipient.
We announced the transfer with an event (this is how MetaMask knows to update your balance).
Finally, we returned true because that's what the ERC20 standard expects on success.
So far, only you can transfer your own tokens. But what if you want a decentralized exchange (DEX) like Uniswap to be able to pull 100 of your tokens to swap them for another asset? You can't give the DEX your private key. That’s where delegation and token approval come in; they let you authorize a third party to spend tokens on your behalf, without handing over full control.
The Token Approval Mechanism
The token approval mechanism is a two-step process:
Approve: You (the owner) call the
approve
function to authorize another address (the spender) to withdraw up to a certain amount of your tokens.TransferFrom: The spender then calls
transferFrom
to move the tokens from your account to a destination.
Let’s add this functionality to our contract.
To implement the approve
function, add the following code snippet:
//...
contract MyToken {
// Previous code...
// Who is allowed to spend what: owner => spender => amount
mapping(address => mapping(address => uint256)) public allowance;
event Approval(
address indexed owner,
address indexed spender,
uint256 value
);
function approve(address spender, uint256 amount) public returns (bool) {
require(spender != address(0), "Can't approve zero address");
// "I allow this spender to use up to this amount of MY tokens"
allowance[msg.sender][spender] = amount;
emit Approval(msg.sender, spender, amount);
return true;
}
}
Next, implement the transferFrom
function:
//...
function transferFrom(
address from,
address to,
uint256 amount
) public returns (bool) {
// Check 1: Does the owner have enough tokens?
require(balances[from] >= amount, "Owner doesn't have enough tokens");
// Check 2: Are YOU allowed to move this many?
require(
allowance[from][msg.sender] >= amount,
"You're not approved for that amount"
);
// Check 3: Valid recipient?
require(to != address(0), "Can't send to zero address");
// Update the allowance FIRST (very important!)
allowance[from][msg.sender] -= amount;
// Then do the transfer
balances[from] -= amount;
balances[to] += amount;
emit Transfer(from, to, amount);
return true;
}
With approve
and transferFrom
, our token is now composable. It can interact with other smart contracts in a secure, decentralized way. The nested mapping for allowance might look intimidating, but it's just a way to record that the owner has allowed this spender to use this amount.
In a real-world scenario using the Uniswap DEX we mentioned earlier, this is how it would look:
You approve(uniswapRouter, 100) - "Uniswap can use 100 of my tokens".
You call Uniswap's swap function.
Uniswap calls transferFrom(you, liquidityPool, 100).
You get ETH back.
It's like writing a check - you specify the amount and recipient, but they decide when to cash it.
This approval system seems bulletproof, right? You control who can spend your tokens and how much. What could go wrong?
Well, here's where things get spicy. This approval system has a hidden flaw. Imagine writing someone a check, then trying to write them a smaller check to replace it - they might cash both. This exact problem has been used to steal tokens from real projects.
For example:
Alice approves Bob for 100 tokens.
Alice changes her mind and sends a transaction to reduce it to 50.
Bob sees Alice's pending transaction.
Bob quickly uses the current 100 approval.
Alice's new 50 approval goes through.
Bob uses the 50 approval, too.
Bob got 150 tokens instead of 50! 😱
So, how do you avoid this scenario? The fix involves modifying the contract by adding increaseAllowance and decreaseAllowance functions, thereby making approval changes atomic and preventing race conditions.
//...
function increaseAllowance(
address spender,
uint256 addedValue
) public returns (bool) {
allowance[msg.sender][spender] += addedValue;
emit Approval(msg.sender, spender, allowance[msg.sender][spender]);
return true;
}
function decreaseAllowance(
address spender,
uint256 subtractedValue
) public returns (bool) {
uint256 currentAllowance = allowance[msg.sender][spender];
require(currentAllowance >= subtractedValue, "Can't decrease below zero");
allowance[msg.sender][spender] -= subtractedValue;
emit Approval(msg.sender, spender, allowance[msg.sender][spender]);
return true;
}
These functions fix the race condition by changing HOW we update approvals:
increaseAllowance
: Adds to the current allowance instead of replacing it.decreaseAllowance
: Subtracts from the current allowance (with a safety check).Both are atomic operations - no window for attackers to exploit.
If Bob has 100 and you decrease by 50, he ends up with exactly 50.
Our token is now secure, feature-complete, and ready for production. However, there's one important question we haven't answered: how will other smart contracts recognize that our token has these exact functions? When Uniswap tries to interact with our token, how does it know we have transfer
, approve
, and transferFrom
?
This is where standards come in. Let's implement the IERC20 interface.
Making It Standard: The IERC20 Interface
Our token works great, but how do other contracts know what functions it has? This is where interfaces shine; they're like a contract's API specification that shows certain functions exist.
Let's implement the standard IERC20 interface:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
interface IERC20 {
// Read functions
function totalSupply() external view returns (uint256);
function balanceOf(address account) external view returns (uint256);
function allowance(address owner, address spender) external view returns (uint256);
// Write functions
function transfer(address to, uint256 amount) external returns (bool);
function approve(address spender, uint256 amount) external returns (bool);
function transferFrom(address from, address to, uint256 amount) external returns (bool);
// Events
event Transfer(address indexed from, address indexed to, uint256 value);
event Approval(address indexed owner, address indexed spender, uint256 value);
}
contract MyToken is IERC20 {
// Our implementation...
}
The Interface implementation above gives our contract:
Composability: Any contract expecting IERC20 can work with your token.
Type Safety: The compiler ensures you implement every required function.
Documentation: Clear specification of what your contract can do.
Standards: Following ERC20 means instant compatibility with thousands of dApps.
Congratulations! You've just built a functional, simplified ERC20 token from scratch. Here is the complete code for MyToken.sol
.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
interface IERC20 {
function totalSupply() external view returns (uint256);
function balanceOf(address account) external view returns (uint256);
function allowance(
address owner,
address spender
) external view returns (uint256);
function transfer(address to, uint256 amount) external returns (bool);
function approve(address spender, uint256 amount) external returns (bool);
function transferFrom(
address from,
address to,
uint256 amount
) external returns (bool);
event Transfer(address indexed from, address indexed to, uint256 value);
event Approval(
address indexed owner,
address indexed spender,
uint256 value
);
}
contract MyToken is IERC20 {
// Token metadata
string public name = "MyToken";
string public symbol = "MTK";
uint8 public decimals = 18;
uint256 public totalSupply;
// Contract owner (can mint new tokens)
address public owner;
// Balances ledger
mapping(address => uint256) public balances;
// Allowances ledger
mapping(address => mapping(address => uint256)) public allowance;
constructor() {
owner = msg.sender;
}
function balanceOf(address account) public view returns (uint256) {
return balances[account];
}
function mint(address to, uint256 amount) public {
require(msg.sender == owner, "Only owner can mint");
require(to != address(0), "Cannot mint to zero address");
totalSupply += amount;
balances[to] += amount;
emit Transfer(address(0), to, amount);
}
function transfer(address to, uint256 amount) public returns (bool) {
require(balances[msg.sender] >= amount, "Insufficient balance");
require(to != address(0), "Cannot transfer to zero address");
balances[msg.sender] -= amount;
balances[to] += amount;
emit Transfer(msg.sender, to, amount);
return true;
}
function approve(address spender, uint256 amount) public returns (bool) {
require(spender != address(0), "Cannot approve zero address");
allowance[msg.sender][spender] = amount;
emit Approval(msg.sender, spender, amount);
return true;
}
function transferFrom(
address from,
address to,
uint256 amount
) public returns (bool) {
require(balances[from] >= amount, "Insufficient balance");
require(
allowance[from][msg.sender] >= amount,
"Insufficient allowance"
);
require(to != address(0), "Cannot transfer to zero address");
allowance[from][msg.sender] -= amount;
balances[from] -= amount;
balances[to] += amount;
emit Transfer(from, to, amount);
return true;
}
function increaseAllowance(
address spender,
uint256 addedValue
) public returns (bool) {
allowance[msg.sender][spender] += addedValue;
emit Approval(msg.sender, spender, allowance[msg.sender][spender]);
return true;
}
function decreaseAllowance(
address spender,
uint256 subtractedValue
) public returns (bool) {
uint256 currentAllowance = allowance[msg.sender][spender];
require(
currentAllowance >= subtractedValue,
"Decreased allowance below zero"
);
allowance[msg.sender][spender] -= subtractedValue;
emit Approval(msg.sender, spender, allowance[msg.sender][spender]);
return true;
}
}
Testing Your Token
You can test your token step by step right in Remix:
Compile: Hit Ctrl+S or click the Solidity compiler tab.
Deploy: Go to the "Deploy & Run" tab, click "Deploy".
Mint some tokens: Call mint with your address and 1000000000000000000000 (that's 1000 tokens with 18 decimals).
Check your balance: Call balanceOf with your address.
Send tokens: Try transferring to another address.
Test approvals: Approve an address and test transferFrom.
Something to note here is that when you set a balance in your mapping, Solidity performs complex cryptographic calculations to determine exactly where on the blockchain to store that data. And every storage operation has a different gas cost. Let's take a deeper look at storage and gas optimization
Storage and Gas Optimization
Now that you understand the basics, let's take a closer look at how Solidity stores your token data and how to optimize for gas costs.
How Storage Slots Work
When you write:
balances[0x123...] = 1000;
Solidity doesn't just put this somewhere random. It computes:
bytes32 slot = keccak256(abi.encode(address(0x123...), uint256(1)));
// Where 1 is the storage slot of the balances mapping
This addressing ensures:
No collisions between different addresses
Predictable gas costs (SSTORE costs 20,000 gas for new values)
Infinite capacity without pre-allocation
Gas Optimization Tips
1. Pack your variables:
// Expensive (uses 3 storage slots)
contract Inefficient {
uint256 a; // Slot 0
uint128 b; // Slot 1 (wastes 128 bits)
uint128 c; // Slot 2 (wastes 128 bits
}
// Cheap (uses 2 storage slots)
contract Efficient {
uint256 a; // Slot 0
uint128 b; // Slot 1 (first half)
uint128 c; // Slot 1 (second half)
}
2. Use events instead of storage for historical data:
Storage: 20,000 gas.
Event: 2,500 gas (8x cheaper).
3. Short-circuit your requirements:
// Check cheap conditions first
require(spender != address(0), "Invalid spender"); // cheap check
require(balances[msg.sender] >= amount, "Insufficient balance"); // expensive check
Common Pitfalls and Security Considerations
Now that you've built a token, let's talk about what could go wrong and how we've protected against it:
1. Integer Overflow (Pre-Solidity 0.8)
Before Solidity 0.8, numbers could "wrap around" like a car odometer going from 999,999 to 000,000:
uint256 balance = type(uint256).max; // Biggest possible number
balance += 1; // Would wrap to 0!
Think of it like filling a glass of water, once it's full, adding more makes it overflow. Luckily, modern Solidity stops this automatically, but older contracts needed special SafeMath libraries to check every calculation.
2. Reentrancy Protection
To explain this, let’s say you're at an ATM that gives you cash before updating your balance. If you could somehow trigger it multiple times before it updates, you could drain the account. That's reentrancy.
Our token prevents this by always updating balances BEFORE doing anything else:
// Good: Update state before external calls
balances[msg.sender] -= amount; // 1. Update state
balances[to] += amount; // 2. Update state
emit Transfer(msg.sender, to, amount); // 3. Then emit event
// Bad: External call before state update
emit Transfer(msg.sender, to, amount); // If this calls external code...
balances[msg.sender] -= amount; // ...they could call transfer again!
balances[to] += amount;
3. Front-Running Protection
Front-running is like someone cutting in line at the store because they saw what you're buying. On blockchain, bots can see your pending transaction and submit their own with higher gas fees to go first.
The approval race condition we fixed earlier is one example. Here are others:
Maximal Extractable Value (MEV) Bots: Automated programs that watch for profitable trades and jump in front. Like ticket scalpers, but for blockchain transactions.
Sandwich Attacks: A bot sees you trying to buy tokens on a DEX. They buy first (driving up the price), let your transaction go through at the higher price, then sell immediately after. You're the "meat" in their profit sandwich.
Priority Gas Auctions: When multiple bots compete to front-run, they keep raising gas prices to go first, like an auction where everyone loses except miners.
What You've Accomplished
Take a moment to appreciate what you've built. This isn't just a toy, it's a real token contract that could be deployed on any EVM chain.
You now understand:
State & Storage: How data lives forever on the blockchain.
Access Control: Using msg.sender to secure functions.
Events: Broadcasting actions to the world.
View Functions: Free data reads for users.
The Approval Pattern: The foundation of all DeFi.
Security Best Practices: From Reentrancy to Race Conditions.
Interfaces: How contracts guarantee compatibility
Gas Optimization: Making your contracts efficient
What Next?
Your token is alive and functional, but why stop here? The real fun begins when you start adding features that production tokens use. Implement a burn function to allow users to destroy their tokens permanently, a powerful feature for deflationary tokenomics. Add a maximum supply cap to ensure only a fixed amount can ever exist. Develop a pausability feature for emergencies, enabling temporary freezes on all transfers and related activities.
Advanced Extensions to Explore
ERC20Permit: Gasless approvals using signatures
Flash Minting: Create and destroy tokens within a single transaction
Rebase Tokens: Tokens that adjust all balances periodically
Fee-on-Transfer: Tokens that take a percentage on each transfer
Once you're happy with the smart contract, it's time to make it accessible. Create a web interface using ethers.js or web3.js, allowing users to interact with your token without needing to write code. Deploy it to Monad's testnet to experience blazing-fast transactions in a real blockchain environment.
Resources for Continued Learning
Subscribe to my newsletter
Read articles from Idris Olubisi directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by

Idris Olubisi
Idris Olubisi
Software Engineer | Developer Advocate | Technical Writer | Content Creator