Lesson 8 — Upgradeability: transparent and UUPS proxies, storage layout
Upgradeable contracts let a project fix bugs without redeploying. They also create a new bug class: storage collisions, initialiser races, and admin-takeover-via-upgrade. Today: how proxies work and what they break.
A non-upgradeable contract has a strong safety property — once deployed, its code can never change, so users can audit it once and rely on that forever. An upgradeable contract trades that for the ability to fix bugs and add features. The trade is more nuanced than developers usually present: upgradeability adds a new bug class (proxy storage collisions, initialiser races) plus a new trust assumption (whoever controls the upgrade controls the contract's future behaviour). This lesson is the proxy pattern unsugared.
**The proxy pattern.** An upgradeable system uses two contracts: a *proxy* (which holds the actual storage and is the address users interact with) and an *implementation* (which holds the code). The proxy uses `delegatecall` to forward every function call into the implementation — `delegatecall` runs the implementation's code in the proxy's storage and `msg.sender` context. Upgrading means deploying a new implementation contract and pointing the proxy at it; the storage (balances, mappings, ownership) is preserved across upgrades.
**The fundamental constraint: storage layout.** Because proxy storage is shared across implementation versions, the layout of storage variables must be identical between versions or storage corruption occurs. If V1 has `address owner` at slot 0 and `uint256 totalSupply` at slot 1, V2 cannot insert a new variable before either of them — doing so would have V2's `totalSupply` read from the slot that holds `owner`. The fix is appending new variables only at the end, and adding 'storage gaps' (`uint256[50] __gap;`) at the end of each implementation to reserve future slots. Audit reports flag *any* reordering of existing storage in upgrades.
**Transparent vs UUPS proxies.** Two dominant patterns. (1) **Transparent proxy** (older OpenZeppelin default): the proxy contract contains the `upgradeTo` function and a check that admin calls are handled by the proxy directly while user calls fall through to the implementation. Higher gas per call (the admin-check on every call), simpler implementation. (2) **UUPS (Universal Upgradeable Proxy Standard, EIP-1822)**: the `upgradeTo` function lives in the implementation itself, gated by the implementation's own access control. Lower gas per call, but a bug in the upgrade-authorisation logic in any implementation version can permanently break upgradeability. UUPS is now the OpenZeppelin default and the more common pattern in modern protocols.
**Initialiser races and uninitialised proxies.** Because constructors don't run when an implementation is delegate-called by a proxy, upgradeable contracts use an `initialize()` function instead. If `initialize` lacks the `initializer` modifier, anyone can call it after deployment (the Audius bug from lesson 6). Worse: in UUPS proxies, leaving the *implementation* contract uninitialised allows an attacker to call `initialize` on the implementation directly (not on the proxy) and then call `selfdestruct` via a delegate-call from the implementation's own initialised admin context — bricking the implementation for all proxies that delegate-call into it. The fix is the `_disableInitializers()` call in the implementation's constructor.
**The Wormhole upgrade bug (February 2022, $325M).** Wormhole's Solana bridge had an upgrade authorisation flaw: a signature-verification check that didn't validate which guardian had signed an upgrade. The attacker forged guardian signatures for an upgrade payload that, when processed, allowed minting wrapped ETH on Solana without backing. Technically a signature-verification bug, but its impact was amplified because it lived in an upgradeable system: a single bug in the upgrade path produced unbounded loss before the next upgrade could correct it.
**The Audius / OpenZeppelin UUPS initialiser bug (July 2022).** Multiple Audius governance contracts had unguarded `initialize` calls in upgradeable proxies. An attacker called `initialize` on the proxy, set themselves as admin, executed an upgrade to a malicious implementation that drained funds. Identified, OpenZeppelin issued an advisory and patched the standard UUPS template; protocols that had used the older template needed to verify their deployments. The class of bug — uninitialised proxy admin — recurs because the failure mode is invisible until exploited.
**The trust assumption.** Even with all the technical bugs avoided, an upgradeable contract has the social property that whoever controls the upgrade can change the contract's behaviour arbitrarily. A protocol with 'instant admin upgrade' and a single owner is functionally a centralised service masquerading as a smart contract. The convention is upgrades behind a multisig + timelock (e.g., 7-day timelock; users can exit if they see an upgrade they dislike). Reading a protocol's upgrade-admin-address, timelock-delay, and recent-upgrade-history on-chain is part of evaluating whether to trust it with capital.
Example
A storage-layout bug walked through. V1 storage layout: slot 0 = `address owner`, slot 1 = `mapping(address => uint256) balances` (mapping references take one slot for the base; entries hash to scattered slots), slot 2 = `uint256 totalSupply`. Developer deploys V2 and decides to add `uint256 lastDeposit` early in the contract: `uint256 lastDeposit; address owner; mapping(address => uint256) balances; uint256 totalSupply;`. Now V2's `lastDeposit` occupies slot 0, V2's `owner` occupies slot 1, etc. The proxy's storage still contains the V1 layout: the address at slot 0 (the old owner) is now interpreted by V2 as `lastDeposit`, V1's `balances` mapping base at slot 1 is now interpreted as V2's `owner`. Reading the contract gives wrong values for everything; writing corrupts everything. The fix is `address owner; mapping(address => uint256) balances; uint256 totalSupply; uint256 lastDeposit;` — append only. Audit reports treat any storage-layout reorder as high-severity for this reason.
Common mistakes
- Inserting new storage variables anywhere except the end of an implementation. Storage layout must remain stable across upgrades.
- Forgetting `_disableInitializers()` in implementation constructors. Allows the implementation itself to be initialised and bricked.
- Trusting upgradeable contracts without checking upgrade-admin governance. The trust assumption is that whoever controls upgrades is trustworthy.
- Believing transparent proxies are 'safer' than UUPS. Both have the same fundamental bug surface; UUPS just shifts the upgrade logic location.
- Allowing instant admin upgrades with no timelock. Removes the only window users have to react to malicious changes.
Check your understanding
A protocol deploys a UUPS-upgradeable proxy. The implementation contract's constructor is empty (no `_disableInitializers()`). What attack becomes possible?
Key terms covered
Sources & further reading
- Primary
- Primary
- Primary
- Secondary
We prioritise primary sources. Where a topic moves quickly (regulation, security incidents), we re-check sources on the cadence shown by the page's "Next review" date.