ERC-20 vs ERC-721: The Same Machinery With One Mapping Flipped
Read the two token standards as code rather than as concepts. ERC-20 maps an address to an amount, ERC-721 maps a token id to an owner, and every other difference between the two files follows from that one line.
TL;DR
- It is the same machinery with one mapping flipped. ERC-20 maps an address to an amount. ERC-721 maps a token id to an owner.
- A balance means two different things. In ERC-20 it is the thing you own. In ERC-721 it is a counter, and ownership lives in a different table.
- ERC-721 can answer
ownerOf(id). ERC-20 cannot, and no amount of extra code would give it the answer. - Approval is where the two genuinely diverge. ERC-20 approves an amount to a spender. ERC-721 approves one token to one address, or hands an operator the whole collection.
tokenURIis a string. The contract has no idea what it points at, and the image is nowhere in the file.
What is the difference between ERC-20 and ERC-721?
ERC-20 keeps a mapping from an address to an amount. ERC-721 keeps a mapping from a token id to an owner. That single inversion is the whole difference between the two standards, and everything else that looks different between the two files is a consequence of it.
Solmate publishes both, same author and same house style, so what differs is the standard rather than anyone's taste. The storage section of the ERC-20:
uint256 public totalSupply;
mapping(address => uint256) public balanceOf;
mapping(address => mapping(address => uint256)) public allowance;
And the equivalent section of the ERC-721:
mapping(uint256 => address) internal _ownerOf;
mapping(address => uint256) internal _balanceOf;
Read the key type first, as in mappings in Solidity. In ERC-20 the key is an address: hand it an account, get a number. In ERC-721 the key of the important table is an id: hand it a token, get an account. The question each contract answers instantly is the one its key type asks.
Is an NFT just a token with a supply of one?
No, because the two standards store ownership in opposite directions. A supply of one in ERC-20 would still be an amount held by an address, with no way to ask who holds it. ERC-721 stores that answer directly, which is why its transfer is a reassignment rather than a subtraction.
Watch the write paths. This is an ERC-20 transfer, with the comments elided:
function transfer(address to, uint256 amount) public virtual returns (bool) {
balanceOf[msg.sender] -= amount;
unchecked {
balanceOf[to] += amount;
}
emit Transfer(msg.sender, to, amount);
return true;
}
Two arithmetic operations on two counters. Nothing identifies which units moved, because no unit has an identity.
The ERC-721 body does something different:
require(from == _ownerOf[id], "WRONG_FROM");
unchecked {
_balanceOf[from]--;
_balanceOf[to]++;
}
_ownerOf[id] = to;
delete getApproved[id];
emit Transfer(from, to, id);
The balance counters still move, but by exactly one, and they are bookkeeping. The line that actually transfers the token is _ownerOf[id] = to;, a single assignment into a single slot. Everything in the table below follows from it.
| ERC-20 | ERC-721 | |
|---|---|---|
| The ledger maps | address to amount | token id to owner |
| What a balance is | the thing you own | a count of how many you own |
| Moving value | subtract here, add there | reassign one slot |
| Transfer takes | recipient and an amount | sender, recipient and an id |
| Ownership lookup | impossible | ownerOf(id) |
| Approval | an amount, per spender | one token, or every token, per address |
| Divisible | yes, by decimals | no |
Transfer event carries | from, to, value | from, to, id, all indexed |
| Identity of one unit | none, units are interchangeable | the id is the identity |
Why does ERC-721 have ownerOf when ERC-20 does not?
Because ERC-721's main table is already keyed by token id, so the owner is a one-step lookup. ERC-20's table is keyed by address and holds a number, so there is nothing to look an owner up with. The question is not merely unimplemented in ERC-20, it is unaskable.
function ownerOf(uint256 id) public view virtual returns (address owner) {
require((owner = _ownerOf[id]) != address(0), "NOT_MINTED");
}
function balanceOf(address owner) public view virtual returns (uint256) {
require(owner != address(0), "ZERO_ADDRESS");
return _balanceOf[owner];
}
Both are one-line reads of a mapping. ownerOf reads the ownership table, balanceOf reads the counter. The require in ownerOf exists because a mapping has no concept of a missing key: an unminted id answers with the zero address exactly as a burned one does, so the contract turns that shared answer into a revert.
Compare what ERC-20's balanceOf gives you: a number. If ten accounts hold that token you can ask each one what it holds, and never ask which ten to ask. Neither standard enumerates holders, but only ERC-721 goes backwards from a unit to a person.
Why does ERC-20 have decimals and ERC-721 does not?
Because ERC-20 amounts are divisible and ERC-721 ids are not. decimals is a display instruction telling an interface where to put the point in a whole number. An id has no fractional part to display, so the field would mean nothing.
The ERC-20 metadata block carries all three fields:
string public name;
string public symbol;
uint8 public immutable decimals;
The contract's arithmetic never consults decimals, and nothing in transfer divides by it. It is published so a wallet holding 1500000000000000000 can render 1.5, and it is why a balance is always an integer whatever your wallet shows.
ERC-721 has the first two fields and stops. There is no half of token 42, and a transfer is all or nothing because _ownerOf[id] = to; has no partial form. That is non-fungible stated mechanically: an amount splits because it is a number, an id cannot because it is a name. More on what that buys in what are NFTs, really.
How is approval different in ERC-20 and ERC-721?
ERC-20 approves an amount to one spender, recorded in a nested mapping of owner to spender to number. ERC-721 has two separate mechanisms: approve one specific token to one address, or set an operator who may move every token you own in that collection.
The ERC-20 version is a single write:
function approve(address spender, uint256 amount) public virtual returns (bool) {
allowance[msg.sender][spender] = amount;
emit Approval(msg.sender, spender, amount);
return true;
}
One number per owner-spender pair, spent down as it is used. ERC-721 needs two functions, because ids are not amounts:
function approve(address spender, uint256 id) public virtual {
address owner = _ownerOf[id];
require(msg.sender == owner || isApprovedForAll[owner][msg.sender], "NOT_AUTHORIZED");
getApproved[id] = spender;
emit Approval(owner, spender, id);
}
function setApprovalForAll(address operator, bool approved) public virtual {
isApprovedForAll[msg.sender][operator] = approved;
emit ApprovalForAll(msg.sender, operator, approved);
}
getApproved is keyed by id, so it grants one token to one address. isApprovedForAll is keyed by owner and operator with a bool value, so it grants everything at once, including tokens you have not bought yet.
This is the asymmetry people get wrong most often. ERC-20 has one dial and it is a number. ERC-721 has two switches, one narrow and one collection-wide, with nothing in between. The first is consumed on use, cleared by delete getApproved[id]; inside the transfer. The second persists until set back to false.
What does safeTransferFrom check that transferFrom does not?
It asks the recipient whether it can hold the token. After performing the ordinary transfer, safeTransferFrom requires that the destination is either a plain account or a contract returning the specific selector that confirms it implements the receiver handler.
function safeTransferFrom(
address from,
address to,
uint256 id
) public virtual {
transferFrom(from, to, id);
require(
to.code.length == 0 ||
ERC721TokenReceiver(to).onERC721Received(msg.sender, from, id, "") ==
ERC721TokenReceiver.onERC721Received.selector,
"UNSAFE_RECIPIENT"
);
}
The recipient side of that handshake ships in the same file:
abstract contract ERC721TokenReceiver {
function onERC721Received(
address,
address,
uint256,
bytes calldata
) external virtual returns (bytes4) {
return ERC721TokenReceiver.onERC721Received.selector;
}
}
The return value is the only thing checked. A contract that does not implement the function returns something else, or reverts, and the transfer unwinds.
Why does ERC-721 bother and ERC-20 not? A quantity sent somewhere that cannot handle it is stuck there, and the remaining supply carries on without it. A unique token sent to the same place is that token, permanently, with no second copy. Naming an id gives it a fate, and the standard adds a step that asks before committing to one.
Where is the image in an ERC-721 contract?
Nowhere. The metadata section holds a name, a symbol, and a function returning a string. What that string points at, and what is at the other end, is entirely outside the contract's knowledge.
string public name;
string public symbol;
function tokenURI(uint256 id) public view virtual returns (string memory);
Compare that with the ERC-20 metadata block above. Two of the three lines are identical. The third is a function with no body, left for a child contract to implement, as covered in interface vs abstract contract.
The contract stores ownership of an id. Everything people associate with the word NFT, the picture, the traits, the rarity, sits behind whatever string that function returns, and the chain never reads it. If it points at a server and the server goes away, the ownership record is untouched. It simply no longer resolves to anything.
Delete the line and ask what breaks
- Delete
decimalsfrom the ERC-20 and every transfer behaves identically, because no arithmetic consults it. What breaks is display: wallets no longer know where the point goes. - Change ERC-721's
_ownerOftomapping(address => uint256)and you have written an ERC-20.ownerOfstops compiling,getApprovedloses its key, andsafeTransferFromhas nothing unique to protect. - Delete the
requireinsideownerOfand an unminted token returns the zero address, turning a revert into a plausible-looking answer. That is the mapping zero value showing through, and it is why the check is there. - Delete the
requireblock fromsafeTransferFromand it becomestransferFromexactly. The receiver handshake is literally the only difference between the two functions.
Related questions
What is the difference between ERC-20 and ERC-721? ERC-20 maps an address to an amount, so balances are quantities and units are interchangeable. ERC-721 maps a token id to an owner, so each unit has an identity and the contract can report who holds a given one.
Is an NFT an ERC-721? Usually, though not always. ERC-721 is the common standard for unique tokens, and ERC-1155 is the other one you will meet, holding unique and fungible items in a single contract.
Can an ERC-721 token be split into fractions? Not within the standard. Fractionalisation is done by a separate contract that takes custody of the token and issues ERC-20 shares against it: two contracts, not one divisible token.
Why does ERC-721 have safeTransferFrom and ERC-20 does not? Because a unique token sent to a contract that cannot handle it is gone with no replacement. The safe variant calls the recipient and requires the expected selector back before the transfer stands.
Does an ERC-721 contract store the image?
No. It stores a tokenURI string, often just a base string plus the id. The image sits wherever the resulting address points, and the contract never reads it.
What is the difference between approve and setApprovalForAll?
approve lets one address move one specific token, and is cleared when that token moves. setApprovalForAll lets an operator move every token you hold in that collection until you revoke it.
Where to go next
The two standards are far more alike than the vocabulary around them suggests. Both keep a ledger, both emit a Transfer event on every move, both have an approval layer so a marketplace can act for you. Change one mapping's key type and the rest of each file follows.
That makes the pair a good place to practise reading state before behaviour. The method is in how to read a smart contract, and the announcements these contracts leave behind, which is how every wallet builds your history, are in events and indexed.
Tagged