1. Introduction


This article introduces some fundamental Solidity concepts for developers familiar with programming, but new to smart contracts. We’ll build a simplified version of Ethereum’s popular ERC-20 token standard step by step, highlighting important Solidity concepts along the way. Due to space constraints, we can’t cover every topic in full depth, but we’ll touch on the essentials, providing a solid foundation for further exploration. Keep in mind the code here is purely for educational purposes and should not be used in production.

ERC-20 is the Ethereum standard for creating tokens. It ensures tokens behave consistently across wallets, exchanges, and decentralized applications (dApps), making integration straightforward. Learning how the ERC-20 standard works under the hood also leads to a better understanding of common security practices and helps you interact with tokens more confidently, whether you are building smart contracts or simply using DeFi applications.


2. Solidity and the Ethereum Virtual Machine (EVM)

2.1 Things to Know Before Writing Your First Contract

Solidity, Ethereum’s primary programming language, differs significantly from languages like Python or JavaScript. Solidity code doesn’t run on a central server. Instead, it’s executed by the Ethereum Virtual Machine (EVM), a decentralized runtime environment hosted on thousands of nodes around the world.

Deploying a smart contract means uploading compiled bytecode onto the blockchain. Once deployed, this bytecode resides permanently at a unique Ethereum address, similar to a backend API endpoint, but decentralized and immutable.

---
config:
  theme: 'base'
  themeVariables:
    primaryColor: '#1f2020'
    primaryTextColor: '#fff'
    primaryBorderColor: '#FFFFFF'
    lineColor: '#FFFFFF'
    secondaryColor: '#12FF80'
    tertiaryColor: '#fff'
---
flowchart LR
    A[Solidity Smart Contract] --> B[Compiled to EVM Bytecode]
    B --> C[Deployed to Ethereum Network]
    C --> D[Smart Contract
Address: 0xAABB...FF]

Each smart contract deployment or function execution must be triggered by a transaction, either from an Externally Owned Account (EOA), like a user’s wallet, or from another contract. But there’s a catch: each operation on the EVM has a gas cost. Gas is paid in ETH and compensates nodes for executing computation and storing data.

Without going into too much detail, different operations (“Opcodes”) on the EVM have different gas prices attached to them depending on their complexity and supplied parameters. Becoming a good Solidity developer means being aware of these underlying mechanisms and the resulting costs to end users.

The Ethereum consensus mechanism ensures that the result of executing a function call is identical on every node in the network. This is essential for maintaining a globally consistent state.

2.2 Exploring the Basic Contract Structure

Let’s begin with a minimal ERC-20 contract. We’ll gradually extend its functionality throughout this article. Solidity uses contracts as building blocks, much like classes in object-oriented programming. A new contract can be declared using the contract keyword.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.3;

contract SimpleERC20 {
    string private _name;
    string private _symbol;
    uint8 private _decimals;
    uint256 private _totalSupply;

    mapping(address => uint256) private _balances;

    constructor(string memory name_, string memory symbol_, uint8 decimals_, uint256 totalSupply_) {
        _name = name_;
        _symbol = symbol_;
        _decimals = decimals_;
        _totalSupply = totalSupply_;
        _balances[msg.sender] = totalSupply_;
    }

    function balanceOf(address account) public view returns (uint256) {
        return _balances[account];
    }
}

Token Metadata

Following the contract declaration we defined multiple state variables: _name, _symbol, _decimals, _totalSupply and _balances using different types. These leading underscores are a common naming convention in Solidity to distinguish private or internal state variables from their public-facing accessors. In this tutorial, we’ll refer to the public-facing names (like decimals), even though the internal variables are named _decimals, _name etc. for clarity and convention.

The state variables are based on the ERC-20 standard:

  • name: A human-readable name of the token (e.g., “Simple Token”).
  • symbol: A short ticker symbol (e.g., “STKN”) as displayed on exchanges or in wallets.
  • decimals: Defines the smallest unit the token can be divided into. Choosing 18 decimals is standard and aligns with Ethereum’s native currency, Ether. This choice affects how the token values are displayed but doesn’t change underlying mechanics.
  • totalSupply: Represents the total number of tokens created. In this simplified example, our total supply is fixed at contract deployment for clarity.

State variables and functions declared as private are only accessible within the defining contract, whereas those declared public are part of the contract’s interface and accessible both internally and externally.

An important point to note here is that Solidity (and the EVM) has no native concept of floating-point numbers. Instead, when storing fractional values, we use unsigned integers of different sizes.

🔍 Deep Dive: Token Subdivisions and Decimals

The absence of floating-point numbers in Solidity requires a slight mental shift for developers coming from other programming backgrounds. Since the ability to subdivide a token is often essential, the ERC-20 standard introduces the decimals attribute to handle this.

The idea is simple: set the totalSupply to a value that accounts for the desired number of decimal places. For example, if you want 6 decimal places and a total supply of 100 tokens, each token would be represented as 1 * 10^6 units on-chain. That means totalSupply should be set to 100 * 10^6. In this setup, transferring five tokens corresponds to sending 5,000,000 units. Wallets and dApp UIs will display this as 5.000000 tokens - exactly as intended.

It’s worth noting that the decimals value is purely informational - the contract itself doesn’t use it internally when performing transfers or balance updates. Instead, it’s up to frontends and external tools to read the decimals value and use it for formatting.

However, when implementing smart contracts that deal with tokens using different decimals, you need to normalize values before doing any calculations. For example, a token with 6 decimals treats 1 * 10^6 as one unit, while one with 18 decimals uses 1 * 10^18. Ignoring this can lead to serious miscalculations in swaps or price logic.

The Balances Mapping

At the heart of our token contract is the balances mapping, which keeps track of how many tokens each Ethereum address holds. Think of it as a ledger entry for each wallet address.

mapping(address => uint256) private _balances;

This statement declares a mapping called balances, which takes inputs of the address type and maps them to uint256 values. Because this mapping manages token balances, updating it requires careful consideration to maintain accuracy and security.

🔍 Deep Dive: Understanding Storage Layout in Solidity

Every smart contract on Ethereum has its own isolated and persistent storage - a key-value store where each key is a 256-bit slot index, and each slot holds 32 bytes. Solidity assigns these slots sequentially based on the order in which state variables appear in the contract. Fixed-size types like uint256 or uint8 are stored directly in these slots.

For dynamic types like string, Solidity stores either the data directly (if it’s short enough) or stores a reference to another location in storage. The exact behavior depends on the length of the data, which means we cannot determine the exact layout for these types just from the contract alone.

That’s why for our ERC-20 contract, while we can say that the state variables are assigned storage slots in order, we cannot make definitive claims about how the name or symbol strings are represented internally - especially before deployment or initialization.

Mappings

Mappings do not use sequential storage either. The mapping itself occupies one slot, but the key-value pairs are stored in seemingly random locations derived using hashing. Solidity computes the storage slot for a mapping entry like this:

keccak256(abi.encode(key, slot))

So for balances[0x1234...], the EVM calculates the slot by hashing the ABI-encoded key and the base slot index (e.g., balances might be at slot 4). This spreads out the data and avoids collisions by using the mapping slot as a salt in the hash function.

abi.encode serializes the input values into their binary representation according to Solidity’s Application Binary Interface (ABI) and keccak256 is a cryptographic hash function used widely in Ethereum for deterministic, fixed-length outputs.

2.3 Functions

In our smart contract, we have defined two functions so far: the (optional) constructor, executed once at deployment, and balanceOf, returning the current token balance for a given address. Let’s examine these more closely.

Initializing the Contract with the Constructor

The constructor sets up the initial token distribution and assigns the token metadata:

constructor(string memory name_, string memory symbol_, uint8 decimals_, uint256 totalSupply_) {
    _name = name_;
    _symbol = symbol_;
    _decimals = decimals_;
    _totalSupply = totalSupply_;
    _balances[msg.sender] = totalSupply_;
}

In the constructor, we use msg.sender to assign the initial token balance. This action updates our balances mapping, demonstrating how we interact with storage to record token ownership.

The memory keyword in the constructor arguments tells Solidity to temporarily store the input strings in memory rather than in contract storage, since we only need them during construction to initialize the contract’s permanent state. Solidity requires specifying memory for dynamic types like string in function parameters — without it, the compiler doesn’t know where to place the incoming data, and will throw an error.

Retrieving Balances with balanceOf

The balanceOf function provides a simple way to check how many tokens any wallet currently holds:

function balanceOf(address account) public view returns (uint256) {
    return _balances[account];
}

Here, the view keyword indicates that the function is read only and guarantees not to modify the contract’s state. Because of this, when you call the function externally (for example from a frontend or a script), the Ethereum node can execute it locally without broadcasting a transaction to the network. That means it doesn’t consume any gas since no state changes need to be recorded on-chain. However, if a smart contract calls balanceOf internally as part of a transaction, it still contributes to the overall gas cost because the transaction must be processed and verified by the network. So it’s helpful to distinguish between what happens on-chain and how client applications interact with the contract from the outside.

We can also use the balanceOf function to touch on another point: for any state variable declared public, the Solidity compiler will automatically generate a getter function. This means that instead of manually implementing a function to retrieve token balances, we could have simply declared the balances mapping as public. However, it’s generally good practice for smart contracts to make all externally accessible functionality explicit to avoid introducing unintended security risks.

To demonstrate this practice, we also implement a getter for the other state variables. Here’s how that could look for decimals:

function decimals() public view returns (uint8) {
    return _decimals;
}

The same approach can be applied to name, symbol and totalSupply. You can find the full code in the appendix at the end of this article.


3. Adding Funtionality to the Contract

So far our contract only mints the totalSupply to the deployment wallet, but we don’t have any way to transfer tokens between wallets yet. In this section we’ll implement functions to allow us to achieve that.

3.1 Enabling Token Transfers: The transfer Function

Unlike native Ether transfers, ERC-20 tokens require every transfer to pass through the token’s smart contract. Recall, that the token distribution is stored inside the balances mapping, which can only be modified from inside our contract. This means we need to add a function that updates this mapping to reflect token transfers between wallets.

function transfer(address to, uint256 amount) public returns (bool) {
    require(_balances[msg.sender] >= amount, "Insufficient balance");
    _balances[msg.sender] -= amount;
    _balances[to] += amount;
    emit Transfer(msg.sender, to, amount);
    return true;
}

In this implementation, we first check that the sender (msg.sender) has enough tokens using a require statement. If the condition fails, the EVM reverts the transaction and any state changes made up to that point. Otherwise, we update the balances accordingly. There also exists the tx.origin variable, which refers to the original external account that started the entire transaction chain, but it’s generally discouraged for use in access control since it can lead to security vulnerabilities. msg.sender, on the other hand, always reflects the immediate caller of the function and is the safer and more appropriate choice here.

After updating the balances, we emit a Transfer event to signal that the tokens have moved from one address to another. Events in Solidity are a way for smart contracts to log information during execution. They do not directly affect the contract’s state but are recorded in transaction logs and can be accessed efficiently by off-chain applications like dApps or block explorers.

To use events, we first need to declare them at the top level of the contract:

event Transfer(address indexed from, address indexed to, uint256 value);

This defines a Transfer event with three parameters: the sender, the receiver, and the amount transferred. The indexed keyword allows these values to be used as searchable filters in off-chain queries.

Emitting events in key places like transfers makes it much easier for external applications to track important activity on the blockchain without needing to inspect the contract’s storage directly. It’s a lightweight way to expose signals from within your smart contract to the outside world.

3.2. Allowing Third-Party Transfers: The Approval Mechanism

Now, we can transfer tokens, but only token owners can initiate transfers using the transfer function. To enable more flexible token usage, especially in DeFi protocols and marketplaces, ERC-20 includes an approval mechanism. This allows a token holder to authorize another address (often a smart contract) to spend tokens on their behalf. This pattern is critical for building composable applications.

We implement this with a nested mapping called allowance, where each account can specify how much a given spender is allowed to transfer:

mapping(address => mapping(address => uint256)) public _allowance;

An additional approve function then lets the token owner set this allowance:

function approve(address spender, uint256 amount) public returns (bool) {
    _allowance[msg.sender][spender] = amount;
    emit Approval(msg.sender, spender, amount);
    return true;
}

And transferFrom allows the spender to use the approved amount. Like transfer, it emits a Transfer event to reflect the movement of tokens:

function transferFrom(address from, address to, uint256 amount) public returns (bool) {
    require(_balances[from] >= amount, "Insufficient balance");
    require(_allowance[from][msg.sender] >= amount, "Allowance exceeded");

    _balances[from] -= amount;
    _balances[to] += amount;
    _allowance[from][msg.sender] -= amount;

    emit Transfer(from, to, amount);
    return true;
}

🔍 Deep Dive: ERC-20 Approval Front-Running Attack

The standard approve method in ERC-20 contracts is vulnerable to front-running.

Suppose Alice approves Bob to spend 100 of her tokens. Later she changes her mind and wants to lower this to 50. She sends another approve call with the reduced amount. If Bob is watching the network, he can quickly call transferFrom using the original 100 tokens and set a higher gas fee to get his transaction mined before Alice’s update.

The problem is that once Alice’s new approval goes through, Bob now has permission to spend an additional 50 tokens. In total, he can take 150 tokens instead of the intended 50.

Mitigation Strategies

One workaround for this attack was introduced by OpenZeppelin through the decreaseAllowance and increaseAllowance functions. In the scenario above, Alice would call decreaseAllowance(Bob, 50) to reduce his allowance. If Bob front-runs her and spends 100, her transaction will revert, because the function checks if the remaining allowance is still at least 50 before subtracting it. You can look at this pseudo-code to get an idea of how it works:

address owner = msg.sender;
uint256 currentAllowance = _allowance[owner][spender];
if (currentAllowance < requestedDecrease) {
    revert;
}
approve(owner, spender, currentAllowance - requestedDecrease);

If Bob front-runs and spends part of the allowance before Alice’s call is mined, the transaction will revert. This doesn’t eliminate the risk entirely but it prevents the allowance from being silently updated in a way Alice didn’t intend. However, this workaround has been removed in recent versions of OpenZeppelin’s ERC-20 contract suite.

Another workaround, which doesn’t introduce functions outside of the ERC-20 standard, is to first set the allowance to zero before setting a new value to mitigate the impact of the attack.

4. Defining and Implementing Interfaces

At this point we have built a simplified ERC-20 token contract that includes the most essential functionality: tracking balances, transferring tokens and approving third party transfers. While this implementation covers the core mechanics of ERC-20 we will leave it there for now to keep things focused.

Before wrapping up it is worth introducing one final concept that ties everything together and makes our contract easier to integrate with other smart contracts: interfaces.

An interface in Solidity defines functions without implementing them. Interfaces allow other contracts and applications to interact confidently with your token by clearly defining available functions. It’s common practice in Solidity to name interfaces starting with an I.

Here’s an interface based on our simplified ERC-20 contract:

interface ISimpleERC20 {
    function name() external view returns (string memory);
    function symbol() external view returns (string memory);
    function decimals() external view returns (uint8);
    function totalSupply() external view returns (uint256);

    function balanceOf(address account) 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);
}

Implementing this interface provides clarity, interoperability, and adherence to standardized methods. Other contracts and applications can confidently interact with your token knowing exactly which methods are available.


5. Conclusion

In this tutorial, we built a simplified ERC-20 token from scratch and explored some of the core ideas behind Solidity and Ethereum smart contract development along the way. We looked at how state is stored in the EVM, how mappings and storage slots work under the hood, and why gas efficiency is such a central concern when writing code for the blockchain. We also implemented token transfers, the approval system and discussed potential pitfalls like the front-running vulnerability in the ERC-20 allowance model.

The contract we ended up with is intentionally minimal. It is meant for educational purposes and lacks many safety checks and optimizations you would expect in production-grade code. Its real value lies in helping you understand how ERC-20 tokens function at a low level and where common patterns and best practices come from.

As a next step, you should look at the ERC-20 implementation by OpenZeppelin. Compare it to the code in this article and try to spot what has been added or improved. You might also try to optimize parts of our implementation or expand it with features like increaseAllowance and decreaseAllowance, proper error handling or more fine-grained access control. Finally, spend some time reading the Solidity documentation to deepen your understanding of how the language works in more complex scenarios and play around with the code in Remix IDE.


Appendix: SimpleERC20 Code

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.3;

contract SimpleERC20 {
    string public _name;
    string public _symbol;
    uint8 public _decimals;
    uint256 public _totalSupply;

    mapping(address => uint256) private _balances;
    mapping(address => mapping(address => uint256)) public _allowance;

    event Transfer(address indexed from, address indexed to, uint256 value);
    event Approval(address indexed owner, address indexed spender, uint256 value);

    constructor(
        string memory name_,
        string memory symbol_,
        uint8 decimals_,
        uint256 totalSupply_
    ) {
        _name = name_;
        _symbol = symbol_;
        _decimals = decimals_;
        _totalSupply = totalSupply_;
        _balances[msg.sender] = totalSupply_;
    }

    // --- Manually Implemented Getter Functions ---

    function name() public view returns (string memory) {
        return _name;
    }

    function symbol() public view returns (string memory) {
        return _symbol;
    }

    function decimals() public view returns (uint8) {
        return _decimals;
    }

    function totalSupply() public view returns (uint256) {
        return _totalSupply;
    }

    function balanceOf(address account) public view returns (uint256) {
        return _balances[account];
    }

    // --- Core ERC-20 Functions ---

    function transfer(address to, uint256 amount) public returns (bool) {
        require(_balances[msg.sender] >= amount, "Insufficient balance");
        _balances[msg.sender] -= amount;
        _balances[to] += amount;
        emit Transfer(msg.sender, to, amount);
        return true;
    }

    function approve(address spender, uint256 amount) public returns (bool) {
        _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, "Allowance exceeded");

        _balances[from] -= amount;
        _balances[to] += amount;
        _allowance[from][msg.sender] -= amount;

        emit Transfer(from, to, amount);
        return true;
    }
}