Crypto OS
Technical Crypto OSStage 2 · Smart Contract Engineering

T9 · ERC Standards

What problem do standards actually solve?

Capabilities practiced
BuilderProtocol Literacy
Hands on
Implement an ERC20, then deliberately make transfer return no bool and see which callers fail.
AI Lab
Have AI list common nonstandard ERC-20 implementations. Find one real mainnet example yourself.

A Real-World Problem

You built a staking pool and wrote “supports any ERC20 token” in its documentation. The core logic is simple:

token.transferFrom(msg.sender, address(this), amount);
shares[msg.sender] += amount;

Three things happen in the first three days after launch.

Day one: someone deposits a major stablecoin. The transaction fails, and the error gives no useful clue. You change no code; another token works.

Day two: someone deposits a token that charges a 3% tax on each transfer. The contract receives 97 but records 100. The pool becomes insolvent immediately, without any alert.

Day three: someone deposits a token whose balances change on their own. A user deposits 100, returns the next day, and withdraws 105. The extra 5 comes out of other users’ shares.

You wrote the same code each time. The standard tells you how to use transferFrom, so why did it still fail?

Thought Experiment

First imagine a world without standards.

Every token creator names actions differently: send, pay, or moveTokens, with arguments in different orders.

To show 100 tokens, a wallet needs 100 adapters. An exchange listing those tokens needs 100 more. A lending protocol needs another 100. The cost is N times M; every new token requires everyone else to change code and release again.

Now introduce a standard: agree that the action is called transfer, its arguments say “to whom” and “how much,” and balanceOf returns a balance.

The cost changes from N times M to N plus M. More importantly, something almost magical becomes possible:

An exchange contract written in 2018 can interact with a token invented in 2025, even though their authors never met and neither side needs an upgrade.

That is composability. It does not mean “more features.” It means you do not need to know the other party when you write your code.

But now comes the real turn in this chapter. Look carefully at what a standard specifies: function names, argument types, and return types. It does not, and cannot, guarantee what the world looks like when a function finishes.

A token can have a perfectly standard transfer signature while deducting a 3% fee, calling a function on the recipient, or doing nothing except emitting an event.

Matching signatures do not guarantee matching behavior. Everything that happened over those three days follows from this one sentence.

  1. A standard defines an interface
  2. Interfaces enable composability
  3. An interface cannot constrain behavior
  4. Integrators must therefore verify outcomes themselves
The third step is the easiest to skip, and it is where every trap lies.

Your Decision

How should your staking pool accept tokens? Consider four approaches.

What Happens

Put the four designs beside the three incidents:

DesignHandles no return valueHandles transfer feesHandles changing balancesHandles malicious tokens
Direct callNoNoNoNo
Compatible return handlingYesNoNoNo
Balance-difference accountingYesYesNoNo
AllowlistYesYesYesMost

Two conclusions follow:

First, a standard gives you an interface, not a guarantee. It lets your code call any token; it does not mean every token can be integrated safely. Callability and trust are different, and a standard supplies only the former.

Second, defenses build on each other. Mature protocols use all three: compatible calls, balance-difference accounting, and an allowlist. Each layer blocks a different problem; omit one and that class of incident remains.

These layers will recur later. The AMM in T19 must account for fee-on-transfer tokens in slippage calculations. The lending protocol in T22 must handle collateral whose balance changes itself. Ten minutes of care here will save confusion across both chapters.

Build a Model

Three Layers of a Standard

A token that “complies with ERC20” makes promises of different strengths at three layers:

LayerContentHow strongly is it enforced?
InterfaceFunctions, argument types, return types, eventsThe compiler can check the shape, but no one forces correct implementation
SemanticsWhat functions should do, conservation of balances, when to emit eventsEntirely voluntary; the chain does not check it
EcosystemHow wallets display it, explorers parse it, and DEXs price itDetermined by other people’s code, not yours

Most incidents happen at the second layer: the interface matches, but the semantics do not.

Three Token Standards, Three Jobs

ERC20ERC721ERC1155
UnitDivisible quantityOne indivisible itemEither
IdentityBalance only, no numberEach item has a unique IDA quantity under each ID
TransferQuantitySpecific IDMultiple IDs and quantities in one call
ApprovalQuantity for an addressOne ID or allAll
Recipient protectionNoneCalls back to confirm when sent to a contractSame, with batch support

A short memory aid: ERC20 asks “how much,” ERC721 asks “which one,” and ERC1155 asks “how many of which batch.”

Notice the callback when ERC721 or ERC1155 is sent to a contract. It prevents an NFT being sent to a contract that cannot retrieve it, but it also means the recipient gets control during the transfer. This security property matters; T28 explores it.

The Approval Model

A contract cannot reach into your account and take tokens by itself, so ERC20 uses two steps:

Step one  You → token contract: allowance[you][protocol] = 100
Step two  You → protocol: deposit 100 for me
          Protocol → token contract: transferFrom(you, protocol, 100)
          Token contract checks the allowance, reduces it, and transfers

This solves “how can a contract take funds?” while creating three real problems:

  1. Two transactions. On first use of a protocol, users sign twice. That is poor UX, and the first transaction is an extra Gas cost.
  2. Allowances remain. After use, the approval can still exist. Most wallets default to unlimited allowances, so your wallet may carry dozens of permissions saying some protocol can take all of a given token at any time, even if that protocol is no longer maintained.
  3. Changing an allowance has a race. If you change an allowance from 100 to 50, the spender may use 100 before your change lands, then another 50 afterward.

Permit addresses point 1: the user signs an approval message with a nonce and expiry (no transaction or Gas). The protocol includes that signature in its own transaction, and the token verifies it and sets the allowance. Two transactions become one.

The answer to point 2 is a habit, not code: periodically review and revoke approvals you no longer need. T13 walks you through it.

A Minimal ERC20

Here is a standalone implementation of those ideas. It needs no external library and can compile directly:

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

contract MiniToken {
    string public name = "Mini";
    string public symbol = "MINI";
    uint8 public decimals = 18;          // Note: 18 is common, not mandatory

    uint256 public totalSupply;
    mapping(address => uint256) public balanceOf;
    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(uint256 initialSupply) {
        totalSupply = initialSupply;
        balanceOf[msg.sender] = initialSupply;
        // Minting conventionally emits a transfer from the zero address; offchain indexes use it to reconstruct supply
        emit Transfer(address(0), msg.sender, initialSupply);
    }

    function transfer(address to, uint256 value) external returns (bool) {
        _transfer(msg.sender, to, value);
        return true;
    }

    function approve(address spender, uint256 value) external returns (bool) {
        allowance[msg.sender][spender] = value;
        emit Approval(msg.sender, spender, value);
        return true;
    }

    function transferFrom(address from, address to, uint256 value) external returns (bool) {
        uint256 allowed = allowance[from][msg.sender];
        if (allowed != type(uint256).max) {        // Unlimited allowances do not decrease, saving a storage write
            require(allowed >= value, "allowance");
            allowance[from][msg.sender] = allowed - value;
        }
        _transfer(from, to, value);
        return true;
    }

    function _transfer(address from, address to, uint256 value) internal {
        require(to != address(0), "to zero");
        uint256 bal = balanceOf[from];
        require(bal >= value, "balance");
        balanceOf[from] = bal - value;
        balanceOf[to] += value;
        emit Transfer(from, to, value);
    }
}

Pause on three details:

  • Minting is represented as “transferring from the zero address,” and burning as “transferring to the zero address.” This is an ecosystem convention, not a compiler requirement. Offchain indexers reconstruct supply from it; break the convention and data platforms may display your token incorrectly.
  • Decimals need not be 18. That is simply the common value. Hard-code 18 in your protocol and a token with 6 or 8 decimals will make your calculations wrong by orders of magnitude.
  • Unlimited allowances do not decrease. It is a common optimization, but also means “approve once, valid forever.”

What It Is Called

ERC20Fungible-token standard

The interface for divisible tokens: balances, transfers, approvals, and two events.

It is the minimum common denominator of onchain finance. It specifies an interface, not behavior. That sentence is the heart of this chapter.

ERC721Nonfungible-token standard

Each token has a unique, indivisible ID. Ownership means “who holds this ID,” not “how many do they hold.”

It adds a recipient-confirmation callback for transfers to contracts. That callback is both a safeguard and an entry point that hands control to another contract.

ERC1155Multi-token standard

Manages multiple IDs in one contract. Each ID can have a quantity, and transfers can move batches.

It suits collections with many kinds of item: game assets, tickets, or batches of identical credentials. Batch transfers can reduce costs significantly.

Allowance / ApproveSpending allowance

The amount a holder permits another address to transfer from their account.

Remember three properties: wallets often default to unlimited approval (a wallet choice, not a standard requirement), use does not automatically clear it, and changing it has a race condition.

PermitSignature-based approval

Changes approval from a transaction into an offline signature that the protocol submits in its own transaction.

It saves users one transaction but creates a phishing opportunity: “it is just a signature” can actually be an unlimited approval. Interfaces must show the signature’s contents honestly. T13 covers this in detail.

ComposabilityComposability

Contracts written by different authors at different times can call one another because they share an interface.

That is why standards exist, and the answer to this chapter’s title: a standard lets two pieces of code collaborate even when their authors have never met.

Hands-on

Hands onImplement an ERC20, then deliberately omit the bool return value and see who failsAny Solidity development framework + a local nodeFree; use only a local node and testnet

The purpose is to see for yourself the difference between a mismatched signature and mismatched behavior.

Deploy MiniToken above locally. Exercise transfers, approvals, and delegated transfers. Confirm that each event is emitted.

Copy it and make a version that returns no bool. Change only the signature; leave the logic untouched:

function transfer(address to, uint256 value) external {
    _transfer(msg.sender, to, value);
}

This version can function perfectly onchain, and transfers really happen. It merely disagrees with the interface declaration.

Write two consumers and call each token from each one.

interface IERC20 {
    function transfer(address to, uint256 value) external returns (bool);
    function balanceOf(address account) external view returns (uint256);
}

// Strict version: call according to the declared interface
contract StrictConsumer {
    function push(address token, address to, uint256 value) external {
        require(IERC20(token).transfer(to, value), "transfer failed");
    }
}

// Tolerant version: use a low-level call and inspect return data yourself
contract TolerantConsumer {
    function push(address token, address to, uint256 value) external {
        (bool ok, bytes memory data) = token.call(
            abi.encodeWithSignature("transfer(address,uint256)", to, value)
        );
        // Either no return data, or data decoding to true
        require(ok && (data.length == 0 || abi.decode(data, (bool))), "transfer failed");
    }
}

Run all four combinations of two consumers and two tokens. Record which fail and at what point.

The strict consumer reverts with a token that returns nothing, because the return data is too short to decode as a bool. That is what happened on day one.

Now make a fee-charging version. Change _transfer so it sends 3% to a fee address; leave the rest untouched.

Call it through both consumers: both succeed. It returns true and emits an event. Everything looks right, but the recipient gets 3% less.

This is the climax of the lab: your defensive call handling does nothing here.

Add balance-difference accounting and repeat. Write a third push version that reads the recipient’s balance before and after transfer and uses the difference as “actually received.”

Run it against all three tokens and confirm that it gets the correct received amount for each.

Deploy to a testnet and inspect a block explorer. See whether the explorer displays the two token versions differently. Usually it does not, because it reads events.

Correct display does not mean safe integration. It is worth seeing once. Stay on a testnet; use no real assets.

AI Lab

AI LabAsk AI to list common nonstandard ERC20 implementations, then find a real mainnet exampleLevel 2 · AI Copilot

Use two steps. The second requires actual investigation.

Step one:
List common nonstandard ERC20 implementations in the real world. For each category explain
which part of the standard it violates, which consumers fail, and how to defend correctly.
Rank the categories by their frequency in practice.

Step two:
For the first category, provide an example you are confident exists on mainnet and explain
how I can verify it myself. If you are uncertain about a specific contract, say so clearly.

The model usually produces a useful first list: no boolean return, false instead of revert, transfer fees, rebasing balances, pausing, blacklists, non-18 decimals, zero-address handling, and approvals that must be cleared before changing. Put it on your integration checklist.

The second step exposes a limitation. A model can generate a contract address with impeccable formatting and a valid checksum even when nothing exists there. Check each address in an explorer; you may be surprised by how many are wrong.

This is not just a matter of model strength. It is being asked to recall a precise 42-character string. Verify exact recalled facts yourself; use the model for patterns and enumeration. This lab makes that boundary memorable.

The reliable way to find examples is to inspect popular tokens by transaction volume in an explorer and read their transfer source. Within half an hour, you should find real examples of at least three categories above.

Verify these points yourself after using AI

  • For every nonstandard category it lists, you found at least one real mainnet contract and read its source yourself
  • If it provides a contract address, check each on a block explorer; plausible-looking addresses may be invented
  • Did it confuse transfer fees with self-changing balances? They are different problems
  • Does its compatible transfer code handle zero-length return data correctly?
  • Did it cite a nonexistent EIP number or library function? Verify each one
  • For every defense it proposes, you can say which category it blocks and which it does not

Real-World Case

A large stablecoin that returns no boolean

One of the largest dollar stablecoins has transfer and transferFrom functions that return no value.

It was deployed early, before interface details had fully settled. It is too large to change merely for compliance, so the ecosystem adapted instead. Nearly every major protocol’s transfer wrapper exists partly to handle this token.

The lesson is not “the code was wrong.” In an immutable system, an early implementation can become the de facto standard even when it differs from the written one.

Transfer fees make a pool’s accounts wrong

Some tokens deduct a percentage from every transfer for distribution, burning, or a marketing wallet.

An AMM or lending pool that records amount assumes it received the full amount. The gap starts small, accumulates, and eventually means the last users to exit cannot withdraw.

The reverse path is subtler: the pool also pays a fee when transferring out, so even the output calculation is wrong. Most major protocols explicitly do not support such tokens. That is why allowlists exist.

A callback token standard enables reentrancy2020, a lending protocol

One token standard calls a recipient hook during transfer. The aim is to let a contract know it received funds.

A lending protocol accepted such a token as collateral. An attacker called its deposit function again from the hook, counting the same funds multiple times before the first deposit had finished accounting, then borrowed other assets from the pool.

Two lessons: a token transfer is not necessarily quiet and atomic from your contract’s perspective; and before integrating any token, ask whether its transfer can hand control elsewhere. T28 develops the full reentrancy model.

Unlimited approvals and phishing signatures

Wallets often request unlimited allowances by default, so one click can last forever. After years of use, an active address may accumulate dozens or hundreds of such permissions.

Rather than hacking a protocol, an attacker may get you to sign a permit on an imitation page or approve a malicious contract. Nothing happens immediately. Funds are transferred days later, after you have forgotten the signature.

Defense belongs in two places: wallets must display exactly what is being signed, and users need a habit of periodic revocation.

Change One Variable

What ifThe token has 6 decimals instead of 18

Every calculation that hard-codes 18 is wrong by orders of magnitude. Prices, liquidation thresholds, and interface display all break.

Read decimals from each token contract and cache it, rather than assuming. Also remember that decimals is optional in the standard. A few tokens have no such function, so your call must tolerate failure.

What ifThe token issuer can pause transfers

Your protocol may freeze during the pause: no deposits, withdrawals, or liquidations.

The most dangerous combination is pausable collateral with liquidation that depends on transfer. When prices crash, liquidations cannot execute and the protocol absorbs bad debt.

That is why, when reviewing a token, read its administrator functions after reading transfer.

What ifThe token contract itself is upgradeable

It complies today but may not tomorrow. All your integration assumptions rest on code that can be replaced at any time.

That does not mean you cannot use upgradeable tokens; many major stablecoins are upgradeable. It means your risk list needs another line: who can upgrade, when does it take effect, and is there a delay? T29 treats this as protocol security.

What ifYour protocol accepts only allowlisted tokens

All three opening incidents disappear at once. You lose the selling point that anyone can list a token.

The real question becomes governance: who can add a token, who is responsible for a mistake, and could one vote admit a malicious token? Many incidents begin in that process rather than in code.

Openness and safety involve a real trade-off here. Starting closed and opening as needed is often easier to manage than the reverse.

Takeaway Questions

1
What problem does it solve?

What problem does a standard solve? It does not give tokens more features. It lets code written by strangers work together. To judge a new standard, ask which previously disconnected kinds of thing it connects.

3
Why do we need a token?

Why do we need tokens? This chapter gives an engineering answer: a token is a shared accounting interface. It is useful because other people’s code can consume it directly, without your permission or your API.

9
Who bears the risk?

Who bears the risk? The integrator. A token author can implement any behavior, but if something goes wrong, funds leave your pool. That makes the three layers of defense essential: compatible calls, balance-difference accounting, and an allowlist, each protecting users against a different risk.

Chapter Check

Key idea

Standards enable composability; they are not feature checklists.

Finished this chapter’s lab? Mark it completeView all progress

On this page