Upgradeable Tokens: Where Do the Balances Actually Live?
Most serious ERC-20 tokens are not one contract. They are two: a proxy that holds everything and an implementation that knows everything. Miss that split and you will eventually decode an event wrong, allowlist the wrong address, or watch someone send seven figures to a contract that cannot give them back. This post is the mental model, and the operational consequences for anyone running custody infrastructure against tokens that can change their own code.
delegatecall: borrowed code, your storage
The whole pattern rests on one EVM instruction. When the proxy receives a call it
does not understand, its fallback runs delegatecall into the implementation.
The implementation's bytecode executes, but in the proxy's storage context.
Every SLOAD and SSTORE the implementation performs reads and
writes the proxy's storage slots. msg.sender and msg.value
are preserved from the original call, so access control and payment logic behave as if
the code lived at the proxy.
The consequence people underestimate: all state lives at the proxy
address. Balances, allowances, total supply, ownership, pause flags - all of it
sits in the proxy's slots. The implementation contract is a stencil. Its own storage is
empty and stays empty, because nobody ever executes it in its own context. Query
balanceOf directly on the implementation address and you get zeros for
everyone, on a contract whose code is byte-for-byte the token everyone is trading.
Money sent to the implementation is gone
Follow the model one step further and you get the classic loss path. Someone - a user, a script, an integration that resolved the wrong address from an explorer page - sends tokens or ETH to the implementation address instead of the proxy. The transfer succeeds. The tokens now sit in a balance mapping keyed to the implementation address, inside some other contract's storage, and nothing will ever move them.
Why not? Because the implementation has no state of its own and, in almost every
deployment, no rescue path. Well-built implementations lock themselves at construction
(OpenZeppelin's _disableInitializers pattern exists precisely for this), so
nobody can initialize them, claim ownership, and sweep. That hardening is correct - an
initializable implementation is an attack surface - but it means the funds are not
stuck pending a cleverer engineer. They are stuck by design, forever. Block explorers
flag implementation addresses for a reason, and your deposit and withdrawal tooling
should refuse them for the same reason.
Storage collisions: why slot 0 is a loaded gun
If the proxy and the implementation share one storage space, they can also fight
over it. The naive proxy declares address admin as its first variable -
slot 0. The implementation declares uint256 totalSupply first - also
slot 0. Now the token's mint function happily overwrites the proxy's admin, and whoever
can influence total supply can, with enough patience, become the admin of the proxy.
That is not a hypothetical class of bug; it is the founding war story of the entire
pattern.
EIP-1967 is the fix, and it is beautifully dumb: the proxy keeps its
own metadata - implementation address, admin, beacon - at pseudo-random slots derived
from hashes like keccak256("eip1967.proxy.implementation") - 1. No compiler
will ever assign a sequential variable to a slot that deep in the address space, so the
proxy's bookkeeping and the token's business state can never collide. It also gives the
whole ecosystem a standard place to look: read that slot and you know which code is
really running behind any proxy, no ABI required.
The same discipline governs upgrades. Storage slots are assigned by declaration
order, and old data does not move when new code arrives. So upgrades must be
append-only with respect to storage layout: never reorder variables,
never change their types, never insert a new one in the middle. Add at the end, or
inherit from base contracts that reserved room in advance with a __gap -
a fixed-size array of unused slots that future versions can consume without shifting
everything below it. Break this rule and the new code reads the old data through the
wrong template: balances reinterpreted as flags, addresses as amounts. The chain will
not revert for you. It will just be quietly, catastrophically wrong.
Transparent vs UUPS: where the upgrade button lives
Two mainstream answers to "who is allowed to swap the implementation":
- Transparent proxies keep the upgrade logic in the proxy itself. The proxy checks on every single call whether the caller is the admin (admin calls hit proxy functions, everyone else gets delegated), and that check is a gas tax paid by every user on every transfer, forever. In exchange, the upgrade machinery can never be accidentally removed - it does not live in the part that changes.
- UUPS moves
upgradeTointo the implementation. Calls are cheaper because the proxy is a bare forwarder. The price is a sharp edge: the next implementation must also carry the upgrade function, because that function is the only door. Deploy one version without it - or with its authorization check botched - and the proxy is bricked permanently. No admin key, no governance vote, no recovery. The state is intact, frozen behind code that no longer knows how to change itself.
Neither is wrong. Transparent buys safety with everyone's gas; UUPS buys efficiency with a deployment checklist that must never fail. What matters operationally is knowing which one a token uses, because it tells you where the keys to its future are held.
What this means when you hold other people's assets
Custody systems like to believe that an address is an identity. Proxies break that belief in a specific way: the address you allowlisted keeps its balances and its history, but its behaviour is mutable. The contract you reviewed in January is not necessarily the contract you are calling in June. The practices that kept us honest:
- Decode against the implementation, resolve via the slot. Events and calldata follow the implementation's ABI, not whatever ABI you cached at onboarding. Read the EIP-1967 slot to find the current implementation and decode against that, every time, not once.
- Treat an implementation change as a policy event. Monitor the
slot (and the
Upgradedevent). When it changes, alert, re-review, and decide whether the token still meets your listing criteria. Fee behaviour, pause semantics, blocklists - all of it can change in one transaction that you did not sign and were not asked about. - Simulate at signing time, not approval time. In a custody flow there are hours or days between "policy approved this withdrawal" and "the transaction hits the wire". The token's code can change inside that window. A simulation run immediately before signing is the only check that sees the code you will actually execute against.
- Expect gas estimates to go stale. An upgrade that adds a hook or
a check changes the gas profile of
transfer. Hardcoded gas limits that were generous last month start reverting this month, and the failure will look like yours, not theirs.
None of this requires distrusting upgradeable tokens. It requires refusing to pretend they are immutable. An allowlist entry for a proxy is not a review of a contract; it is a subscription to whatever that contract becomes.
A checklist, if you are building this
- Know, for every listed token, whether it is a proxy, and record both addresses.
- Refuse deposits and withdrawals addressed to known implementation contracts.
- Resolve the implementation via the EIP-1967 slot at decode time, not from a cached ABI.
- Alert on implementation changes and gate them through re-review before further flows.
- Simulate transactions immediately before signing, not only at approval.
- Never hardcode gas limits for token calls; re-estimate after any upgrade.
- If you deploy proxies yourself: append-only storage layouts,
__gapin every base contract, and a UUPS upgrade path exercised on a fork before every release.
The proxy pattern is a good trade: bugs get fixed, standards get adopted, tokens evolve. But it moves the ground under systems that assumed code at an address is a constant. Build for the version that ships next quarter, not the one you audited.