Sending a token moves nothing
Part five split Ethereum into accounts — four fields each, and in a contract account the code decides where the money goes. Part six followed the storageRoot into the trie and found a contract's storage is an array of 2^256 slots, with the balance of a key living at exactly one of them: keccak256(key . slot). Part seven opened the codeHash and watched a real ERC-20's bytecode run one opcode at a time, dispatch on a four-byte selector, and reach into a single slot to answer balanceOf. Three posts, each ending by pointing at the next. This one collects the debt.
Here is the claim, and it sounds wrong the first time: a token is a spreadsheet with one column, and sending a token moves nothing. No coin travels the wire. There is no object called "a token" that changes hands. There is a contract, that contract keeps a mapping(address => uint256) — a column of numbers keyed by address — and "sending" means the contract subtracts from one row and adds to another. That's the entire mechanism. The rest of ERC-20 is paperwork so that every wallet and exchange can plug into the same column layout without asking.
Nothing here is a mockup. The contract the widgets run is the deployed runtime bytecode of ERC20Contract.sol from an exam I actually sat — the "PCI" token from a university course — compiled with solc 0.8.9 and bundled verbatim, the same 6,840 bytes part seven executed. Every balance, allowance and returned true below is a real SLOAD/SSTORE of that bytecode running on this series' mini-EVM, and every storage slot is keccak256 of real bytes. If a number were faked you could catch it with cast.
01 · A contract that remembers a number
My course notes have the cleanest definition I've read: "las fichas del casino tienen valor dentro del casino" — casino chips have value inside the casino. A chip isn't money; it's a claim the house honors, and it only means anything at that house's tables. A token is the same trick made programmable. The "house" is a contract at a fixed address, and "un token es un address de un contrato inteligente" — a token is just the address of a smart contract. Point your wallet at 0xA0b8… and it's dollars-ish (USDC); point it at another address and it's something else. The address is the token.
And what does that contract remember? One mapping:
mapping(address => uint256) public balanceOf; // holder => how many
That's the whole state of ownership. Not a list of coins, not a set of UTXOs like Bitcoin — a single column mapping each holder to a number. totalSupply is the sum; name, symbol and decimals are labels. The reason a thousand tokens all work in the same wallet is that they all agree to expose this same shape — the ERC-20 interface — so the wallet never needs to know what any particular token is. It reads the column and shows you a number. Standardization is the entire product; the contract underneath is almost embarrassingly small.
02 · A balance is one storage slot
Part six gave you the rule for where a mapping entry lives: a value for key k in a mapping declared at slot p sits at keccak256(abi.encode(k, p)) — the key and the slot number, each left-padded to 32 bytes, hashed together. balanceOf is the sixth state variable in this contract, so solc put it at slot 5 (I'm reading that straight off the compiler's own storageLayout.json, not guessing). So the balance of any holder is one hash away: keccak256(pad32(holder) . pad32(5)).
Here's the payoff of that whole trie post, made concrete on the real token. Pick a holder and watch the slot get computed, then watch the same number come back two ways — the abstract balanceOf(holder) call, and a raw SLOAD of that one computed slot:
Things worth watching for:
balanceOf(holder)andSLOAD(slot)are the same read. The high-level call and the raw slot load return the identical number because they are the identical operation — the functionbalanceOfis barely more than "compute that hash andSLOADit." An abstraction you can see straight through to the storage underneath.- An unfunded address isn't special — it's a zero slot. Pick the stranger and the balance is
0, not because the contract has a rule for strangers but because nobody ever wrote that slot, and unwritten storage reads as zero. "Having no tokens" and "having a slot that was never touched" are the same state. - The slot is a keccak, so it's unguessable and uniform. Balances scatter across the 2^256 address space by hash; there's no
balances[0],balances[1]array packed neatly. Each holder's number lives at a pseudo-random-looking slot, and only the holders who exist cost any storage at all.
03 · A call is four bytes and some padding
Part seven ended on the selector — the first four bytes of keccak256(signature) that a dispatcher matches to jump into the right function. transfer(address,uint256) hashes to a9059cbb, and nobody registered that anywhere; it falls out of the hash. What part seven left for here is what comes after those four bytes.
The answer is boringly regular: each argument is padded to its own 32-byte word and concatenated. An address (20 bytes) gets 12 zero bytes bolted on top; a uint256 is already a word. So a transfer call is exactly 4 + 32 + 32 = 68 bytes: selector, recipient, amount. Build one and hover the bytes — change the recipient or amount and watch the calldata recompute:
Things worth watching for:
- This is exactly what MetaMask signs. The ABI — the list of function signatures and types — never goes on-chain. It lives off-chain, in your wallet and in etherscan. What travels is only these bytes, the
tx.datafield. The contract on the other end re-derives meaning from them the same way: read four bytes, jump, thenCALLDATALOADeach argument by offset. - The 12 zero bytes on an address are load-bearing padding. An address is 160 bits but the EVM works in 256-bit words, so it's left-padded. Get the padding wrong — right-pad instead of left, or forget it — and the contract reads a different address entirely. Every "why did my tokens go to the void" story starts here.
- Switch functions and the selector changes, the shape doesn't.
approveis095ea7b3,transferFromis23b872dd, all confirmed against this contract's realmethodIdentifiers.json. Different name, different hash, same layout: four bytes then padded words. Once you can read one call by hand you can read all of them.
04 · The allowance dance
Direct transfer is easy: I move my own tokens. But most of DeFi needs something harder — letting another contract move your tokens on your behalf, without you handing it your private key. An exchange has to be able to pull the tokens you're selling. You can't push tokens to a contract and expect it to react (there's no callback in plain ERC-20), so the standard uses a pull model with two functions and a second mapping:
mapping(address => mapping(address => uint256)) public allowance; // owner => spender => cap
approve(spender, amount) writes allowance[you][spender] = amount — a spending cap, nothing more; no tokens move. Then transferFrom(from, to, amount) lets the approved spender pull tokens, but only up to the cap, decrementing it each time. Two writes and a check, and it's the backbone of every swap, every "approve this dApp" popup you've ever clicked.
This is the centerpiece, and it runs the real bytecode. The three actors are lifted from my exam's README — Carlos deploys and plays the exchange, Juan and Ana hold PCI. Step through transfer → approve → transferFrom and watch balances (slot 5) and the allowance (slot 6, a mapping-of-mappings) update after each real execution:
Things worth watching for:
approvemoves zero tokens. Watch step two: balances don't budge, onlyallowance[Juan][Carlos]appears, sitting at3. Approval is a promise written to storage, not a transfer. (This is also the source of the classic ERC-20 footgun — the approval sits there until spent or reset, and a compromised spender can drain up to the cap.)transferFromdecrements the allowance and moves the tokens in one call. Step three: Carlos, who owns none of these tokens, pulls 3 from Juan to Ana. Juan's balance drops, Ana's rises, and the allowance falls from3to0— the cap is spent. Carlos never needed Juan's key; he needed Juan'sapprove.- The events are real, and the
trueis real. Each successful call returnstrueand emits a genuineTransferorApprovalevent — the widget decodes it from the actualLOGopcode the bytecode fires, whose topic iskeccak256("Transfer(address,address,uint256)")=ddf252ad…. Try totransferFrommore than the allowance elsewhere and the real contractrequires its way to a revert.
A modern note the exam predates: approve/transferFrom is still the standard, but the two-transaction "approve then use" friction spawned EIP-2612 permit — a signed message that sets an allowance in the same transaction that spends it, no separate approval tx. Many tokens now carry it as an extension. The two functions above are still the floor everything builds on.
05 · A real contract, with real quirks
I keep saying "real," so here are the warts, because a compiled artifact doesn't get to be tidy. This token's decimals is declared uint256 (the convention is uint8) and — look at the constructor — it's never assigned, so it reads 0. A wallet would show whole PCI only, no fractional places. That's not a teaching simplification; it's a bug in the contract I'm running, and the mini-EVM reproduces it faithfully because it's running the bytes, not my intentions.
Two more honest edges. First, this contract is hand-rolled, no OpenZeppelin — every require, every manual previousBalance - finalBalance == value assertion is the author's, written back when people didn't fully trust the 0.8 compiler's built-in overflow checks to have their back (they do; those asserts are redundant under 0.8). If you learned ERC-20 from OpenZeppelin's version, note that OZ removed the increaseAllowance/decreaseAllowance helpers in v5 — a lot of tutorials still teach them, and they're gone from current OZ; don't reach for them.
Second, the disclaimer that matters most for what's coming: the mini-EVM runs this token standalone — no cross-contract calls. That's honest and fine for a token, whose transfer/approve/transferFrom only ever touch their own storage. But the exam's exchange — the contract that would mint PCI, take ETH, refund change, and pull tokens via the very transferFrom you just stepped through — orchestrates several contracts at once, with .call and .transfer between them. My interpreter has no CALL. That interplay, and the bug hiding in it, is the next post's whole subject.
06 · What's next
The mental model, compressed: a token is a contract that remembers a column of numbers, mapping(address => uint256). A balance is one storage slot, keccak256(holder . 5), and balanceOf is barely more than the hash and an SLOAD. A call is a four-byte selector — the hash of the signature — followed by each argument padded to a 32-byte word, and that's the only thing that travels on-chain. "Sending" subtracts from one slot and adds to another; nothing moves. And letting someone spend for you is a second mapping, allowance, written by approve and drained by transferFrom. Every number on this page came out of the real compiled bytecode.
Honesty in one place: the token is a real exam contract with real quirks (decimals stuck at 0, hand-rolled asserts, no OZ); the mini-EVM runs it standalone with no sub-calls; and the gas figures, as part seven said, are a Berlin/London approximation, not a consensus-exact meter. The bytes, though, are the compiler's, and every balance and allowance is a live SLOAD.
Next, The audit: I audited my own homework. The same exam repo ships an exchange contract full of the mistakes 2022-me made — one function that pays out ETH but forgets to pull the tokens it's paying for, a fistful of > that should be >= — and we'll run the exploit against the real bytecode and watch it drain. Reentrancy, front-running, the whole SWC catalog, with my own buggy contracts as the crime scene.
Same promise as always: it's bytes all the way down, no magic, and you can single-step every one of them.