Token standards
| Standard | Token kind | Balance model |
|---|---|---|
| ERC-20 | Fungible | mapping(address => uint256) |
| ERC-721 | Non-fungible | One owner per token ID |
| ERC-777 | Fungible, with hooks | Like ERC-20 plus callbacks |
| ERC-1155 | Multi-token | mapping(id => mapping(address => uint256)) |
| ERC-998 | Composable NFT | An NFT that owns other tokens |
| ERC-4626 | Tokenised vault | ERC-20 shares over an ERC-20 asset |
ERC-20
The fungible token interface. Every transfer is its own transaction, and moving several different tokens means several transactions.
interface IERC20 {
function totalSupply() external view returns (uint256);
function balanceOf(address account) external view returns (uint256);
function transfer(address to, uint256 amount) external returns (bool);
function allowance(address owner, address spender) external view returns (uint256);
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);
}
The approve race
approve sets an allowance rather than adjusting it. Changing a non-zero
allowance to another non-zero value lets a watching spender front-run the change
and spend both amounts. Set the allowance to zero first, or use
increaseAllowance / decreaseAllowance.
Inconsistent return values
The standard says transfer returns a bool, but several widely-held tokens —
USDT among them — return nothing and revert on failure instead. A contract that
assumes a return value fails to decode the response and reverts against those
tokens.
Use OpenZeppelin's SafeERC20, or the pattern in
Gas optimization, which accepts both shapes.
Decimals
decimals is display metadata only. On-chain amounts are always integers —
there are no fractions in the EVM. A token with 6 decimals (USDC) and one with
18 (most others) need different scaling, and hard-coding 18 is a common and
expensive bug.
ERC-1155
One contract holds many token types, each identified by an ID, and each type may be fungible or non-fungible. Balances are per ID per address.
Several items move in a single transaction:
And a batch can fan out to several recipients:
function safeBatchTransferFrom(
address from,
address to,
uint256[] calldata ids,
uint256[] calldata amounts,
bytes calldata data
) external;
Batching is the point: one approval covers every token type in the contract, and one transaction moves any combination of them. For a game handing out dozens of item types this is dramatically cheaper than deploying an ERC-20 or ERC-721 per item.
The safe prefix means the transfer calls onERC1155Received on a recipient
contract and reverts if it does not acknowledge — this is what stops tokens
being sent to a contract that cannot move them again. Note the callback also
hands control to the recipient, so apply the checks-effects-interactions
ordering around it.
ERC-1155 and ERC-998 compared
Both aggregate multiple tokens, but differently.
Storage model
- ERC-998 merges existing ERC-721 and ERC-20 tokens into a single ERC-998 token — a composable NFT that owns other tokens, which keep their own contracts.
- ERC-1155 registers fungible, non-fungible and semi-fungible types inside one contract, with no separate token contracts involved.
Primary use
- ERC-1155 was created by Enjin for gaming, so a player's fungible currency and non-fungible items live in one place and move together.
- ERC-998 was aimed at portfolio composition — bundling holdings under a single transferable token.
Either can be used for the other purpose. ERC-1155 saw far wider adoption; ERC-998 remains a draft and has little tooling or marketplace support, which is the practical reason to prefer ERC-1155 unless composability over existing tokens is the actual requirement.
Implementations
Use audited implementations rather than writing your own:
npm install @openzeppelin/contracts
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {ERC1155} from "@openzeppelin/contracts/token/ERC1155/ERC1155.sol";
contract MyToken is ERC20 {
constructor() ERC20("MyToken", "MTK") {
_mint(msg.sender, 1_000_000 * 10 ** decimals());
}
}
OpenZeppelin 5.x changed several things from 4.x: Ownable takes an initial
owner argument, _beforeTokenTransfer was replaced by _update, and several
extensions moved. Check the migration guide rather than assuming a tutorial
written against 4.x still compiles.