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.
- A standard defines an interface
- Interfaces enable composability
- An interface cannot constrain behavior
- Integrators must therefore verify outcomes themselves
Your Decision
How should your staking pool accept tokens? Consider four approaches.
What Happens
Put the four designs beside the three incidents:
| Design | Handles no return value | Handles transfer fees | Handles changing balances | Handles malicious tokens |
|---|---|---|---|---|
| Direct call | No | No | No | No |
| Compatible return handling | Yes | No | No | No |
| Balance-difference accounting | Yes | Yes | No | No |
| Allowlist | Yes | Yes | Yes | Most |
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:
| Layer | Content | How strongly is it enforced? |
|---|---|---|
| Interface | Functions, argument types, return types, events | The compiler can check the shape, but no one forces correct implementation |
| Semantics | What functions should do, conservation of balances, when to emit events | Entirely voluntary; the chain does not check it |
| Ecosystem | How wallets display it, explorers parse it, and DEXs price it | Determined 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
| ERC20 | ERC721 | ERC1155 | |
|---|---|---|---|
| Unit | Divisible quantity | One indivisible item | Either |
| Identity | Balance only, no number | Each item has a unique ID | A quantity under each ID |
| Transfer | Quantity | Specific ID | Multiple IDs and quantities in one call |
| Approval | Quantity for an address | One ID or all | All |
| Recipient protection | None | Calls back to confirm when sent to a contract | Same, 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 transfersThis solves “how can a contract take funds?” while creating three real problems:
- Two transactions. On first use of a protocol, users sign twice. That is poor UX, and the first transaction is an extra Gas cost.
- 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.
- 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
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.
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.
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.
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.
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.
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
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
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
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.
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.
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.
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
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.
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.
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.
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
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.
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.
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
You decode the return value according to the interface, but the token returns nothing. The return data is too short to decode as a bool, so decoding fails and the transaction reverts.
Use a low-level call and require both success and either empty return data or data that decodes to true. Major transfer-wrapper libraries follow this pattern.
No. It says only “no error,” not “how much arrived.”
Fee-on-transfer tokens return true. Rebasing tokens can also comply on each transfer. To know what actually arrived, read the balance before and after.
Beyond that, malicious tokens can do anything in any function, so you still need an allowlist.
You change an address’s allowance from 100 to 50. The spender sees your transaction pending and uses 100 before it lands. After your transaction sets 50, the spender uses another 50: 150 in total.
A historical mitigation is to require clearing the allowance to zero before setting a new value; some tokens enforce that. Another approach is a permit for an exact amount with an expiry.
Both.
As protection, it prevents an NFT from being transferred into a contract that cannot handle it, potentially locking it forever.
As risk, it hands control to the recipient contract during transfer. The recipient can call you again from the callback. Any “transfer, then account” sequence can be reentered before accounting.
A simple check: after the transfer line, is there code relying on the state from before transfer?
There is no single prescribed answer. Start here:
- What do
transferandtransferFromreturn? Does failure revert or return false? - Does the full amount arrive? Are there fees, burns, or distributions?
- Can balances change on their own through rebasing or interest accrual?
- How many
decimalsdoes it have? Does that function exist? - Can an administrator pause, freeze, blacklist, mint, or upgrade? Who holds those permissions and is there a delay?
- Can a transfer call an external contract through callbacks, hooks, or extensions?
- Does total supply equal the sum of balances? Does the zero address hold a balance?
One final rule for the list: if you cannot answer any one item, do not allowlist the token yet. Not knowing is itself an answer.
Key idea
Standards enable composability; they are not feature checklists.