Module 6: Proxy Patterns & Upgradeability
Difficulty: Advanced
Estimated reading time: ~25 minutes | Exercises: ~6-8 hours
๐ Table of Contents
Proxy Fundamentals
- Why Proxies Matter for DeFi
- How Proxies Work
- Transparent Proxy Pattern
- UUPS Pattern (ERC-1822)
- Beacon Proxy
- Diamond Pattern (EIP-2535) โ Awareness
Storage Layout and Initializers
๐ก Why Proxies Matter for DeFi
Why this matters: Every major DeFi protocol uses proxy patternsโAave V3, Compound V3, Uniswapโs periphery contracts, MakerDAOโs governance modules. The Compound COMP token distribution bug ($80M+ at risk) would have been fixable with a proxy pattern. Understanding proxies is non-negotiable for reading production code and deploying your own protocols.
In Part 2, youโll encounter: Aave V3 (transparent proxy + libraries), Compound V3 (custom proxy), MakerDAO (complex delegation patterns).
๐ Deep dive: Read EIP-1967 to understand how proxy storage slots are chosen (specific slots to avoid collisions).
๐ก Proxy Fundamentals
๐ก Concept: How Proxies Work
The core mechanic:
A proxy contract delegates all calls to a separate implementation contract using DELEGATECALL. The proxy holds the storage; the implementation holds the logic. Upgrading means pointing the proxy to a new implementationโstorage persists, logic changes.
User โ Proxy (storage lives here)
โ DELEGATECALL
Implementation V1 (logic only, no storage)
After upgrade:
User โ Proxy (same storage, same address)
โ DELEGATECALL
Implementation V2 (new logic, reads same storage)
โ ๏ธ The critical constraint: Storage layout must be compatible across versions. If V1 stores uint256 totalSupply at slot 0 and V2 stores address owner at slot 0, the upgrade corrupts all data. This is the #1 source of proxy-related exploits.
๐ป Quick Try:
Paste this into Remix to feel how DELEGATECALL works:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract Implementation {
uint256 public value; // slot 0
function setValue(uint256 _val) external { value = _val; }
}
contract Proxy {
uint256 public value; // slot 0 โ SAME layout as Implementation
address public impl;
constructor(address _impl) { impl = _impl; }
fallback() external payable {
(bool ok,) = impl.delegatecall(msg.data);
require(ok);
}
}
Deploy Implementation, then deploy Proxy with the implementation address. Call setValue(42) on the Proxy โ then read value from the Proxy. Itโs 42! But read value from Implementation โ itโs 0. The proxyโs storage changed, not the implementationโs. Thatโs DELEGATECALL.
โ ๏ธ Notice that
implat slot 1 could be overwritten by the implementation contract โ this is exactly why EIP-1967 random storage slots exist (covered below).
โ ๏ธ Common Mistakes
// โ WRONG: Using call instead of delegatecall
fallback() external payable {
(bool ok,) = impl.call(msg.data); // Runs in implementation's context!
// Storage writes go to the IMPLEMENTATION, not the proxy
}
// โ
CORRECT: delegatecall executes in the caller's (proxy's) storage context
fallback() external payable {
(bool ok,) = impl.delegatecall(msg.data);
require(ok);
}
// โ WRONG: Proxy state stored in normal slots โ collides with implementation
contract BadProxy {
address public implementation; // slot 0 โ collides with implementation's slot 0!
fallback() external payable {
(bool ok,) = implementation.delegatecall(msg.data);
require(ok);
}
}
// โ
CORRECT: Use EIP-1967 slots for proxy-internal state
// Derived from hashing a known string โ collision-resistant
bytes32 constant _IMPL_SLOT =
bytes32(uint256(keccak256("eip1967.proxy.implementation")) - 1);
// โ WRONG: Expecting the implementation's state to change
Implementation impl = new Implementation();
Proxy proxy = new Proxy(address(impl));
proxy.setValue(42);
impl.value(); // Returns 0, NOT 42! The proxy's storage changed, not impl's
๐ก Concept: Transparent Proxy Pattern
OpenZeppelinโs pattern, defined in TransparentUpgradeableProxy.sol
How it works:
Separates admin calls from user calls:
- If
msg.sender == admin: the proxy handles the call directly (upgrade functions) - If
msg.sender != admin: the proxy delegates to the implementation
This prevents:
- The admin from accidentally calling implementation functions
- Function selector clashes between proxy admin functions and implementation functions
๐ Trade-offs:
| Aspect | Pro/Con | Details |
|---|---|---|
| Mental model | โ Pro | Simple to understand |
| Admin safety | โ Pro | Admin canโt accidentally interact with implementation |
| Gas cost | โ Con | Every call checks msg.sender == admin (~100 gas overhead) |
| Admin limitation | โ Con | Admin address can never interact with implementation |
| Deployment | โ Con | Extra contract (ProxyAdmin) |
Evolution: OpenZeppelin V5 moved the admin logic to a separate ProxyAdmin contract to reduce gas for regular users.
โก Common pitfall: Trying to call implementation functions as admin. Youโll get
0x(empty) return data because the proxy intercepts it. Use a different address to interact with the implementation.
๐ Deep Dive: EIP-1967 Storage Slots
The problem: Where does the proxy store the implementation address? If it uses slot 0, it collides with the implementationโs first variable. If it uses any normal slot, thereโs a risk of collision with any contract.
The solution: EIP-1967 defines specific storage slots derived from hashing a known string, making collision practically impossible:
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ EIP-1967 Storage Slots โ
โ โ
โ Implementation slot: โ
โ bytes32(uint256(keccak256("eip1967.proxy.implementation")) - 1) โ
โ = 0x360894...bef9 (slot for implementation address) โ
โ โ
โ Admin slot: โ
โ bytes32(uint256(keccak256("eip1967.proxy.admin")) - 1) โ
โ = 0xb53127...676a (slot for admin address) โ
โ โ
โ Beacon slot: โ
โ bytes32(uint256(keccak256("eip1967.proxy.beacon")) - 1) โ
โ = 0xa3f0ad...f096 (slot for beacon address) โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Why - 1? The subtraction prevents the slot from having a known preimage under keccak256. This is a security measure โ without it, a malicious implementation contract could theoretically compute a storage variable that lands on the same slot.
Reading these slots in Foundry:
// Read the implementation address from any EIP-1967 proxy
bytes32 implSlot = bytes32(uint256(keccak256("eip1967.proxy.implementation")) - 1);
address impl = address(uint160(uint256(vm.load(proxyAddress, implSlot))));
Where youโll use this: Reading proxy implementations on Etherscan, verifying upgrades in fork tests, building monitoring tools that track implementation changes.
๐ก Concept: UUPS Pattern (ERC-1822)
Why this matters: UUPS is now the recommended pattern for new deployments. Cheaper gas, more flexible upgrade logic. Used by: Uniswap V4 periphery, modern protocols.
Defined in ERC-1822 (Universal Upgradeable Proxy Standard)
How it works:
Universal Upgradeable Proxy Standard puts the upgrade logic in the implementation, not the proxy:
// Implementation contract
contract VaultV1 is UUPSUpgradeable, OwnableUpgradeable {
function _authorizeUpgrade(address newImplementation) internal override onlyOwner {}
// ... vault logic
}
The proxy is minimal (just DELEGATECALL forwarding). The implementation includes upgradeTo() inherited from UUPSUpgradeable.
๐ Trade-offs vs Transparent:
| Feature | UUPS โ | Transparent |
|---|---|---|
| Gas cost | Cheaper (no admin check) | Higher (~100 gas/call) |
| Flexibility | Custom upgrade logic per version | Fixed upgrade logic |
| Deployment | Simpler (no ProxyAdmin) | Requires ProxyAdmin |
| Risk | Can brick if upgrade logic is missing | Safer for upgrades |
โ ๏ธ UUPS Risks:
- If you deploy an implementation without the upgrade function (or with a bug in it), the proxy becomes non-upgradeable forever
- Must remember to include UUPS logic in every implementation version
โก Common pitfall: Forgetting to call
_disableInitializers()in the implementation constructor. This allows someone to initialize the implementation contract directly (not through the proxy), potentially causing issues.
๐๏ธ Real usage:
OpenZeppelin UUPS implementation โ production reference.
๐ Deep dive: OpenZeppelin - UUPS Proxy Guide provides official documentation. Cyfrin Updraft - UUPS Proxies Tutorial offers hands-on Foundry examples. OpenZeppelin - Proxy Upgrade Pattern covers best practices and common pitfalls.
๐ Intermediate Example: Minimal UUPS Proxy
Before using OpenZeppelinโs abstractions, understand whatโs happening underneath. Hereโs a stripped-down UUPS pattern:
// Minimal UUPS Proxy โ for understanding, not production!
contract MinimalUUPSProxy {
// EIP-1967 implementation slot
bytes32 private constant _IMPL_SLOT =
bytes32(uint256(keccak256("eip1967.proxy.implementation")) - 1);
constructor(address impl, bytes memory initData) {
// Store implementation address
assembly { sstore(_IMPL_SLOT, impl) }
// Call initialize on the implementation (via delegatecall)
if (initData.length > 0) {
(bool ok,) = impl.delegatecall(initData);
require(ok, "Init failed");
}
}
fallback() external payable {
assembly {
// Load implementation from EIP-1967 slot
let impl := sload(_IMPL_SLOT)
// Copy calldata to memory
calldatacopy(0, 0, calldatasize())
// Delegatecall to implementation
let result := delegatecall(gas(), impl, 0, calldatasize(), 0, 0)
// Copy return data
returndatacopy(0, 0, returndatasize())
// Return or revert based on result
switch result
case 0 { revert(0, returndatasize()) }
default { return(0, returndatasize()) }
}
}
}
// The implementation includes the upgrade function
contract VaultV1 {
// EIP-1967 slot (same constant)
bytes32 private constant _IMPL_SLOT =
bytes32(uint256(keccak256("eip1967.proxy.implementation")) - 1);
address public owner;
uint256 public totalDeposits;
function initialize(address _owner) external {
require(owner == address(0), "Already initialized");
owner = _owner;
}
// UUPS: upgrade logic lives in the implementation
function upgradeTo(address newImpl) external {
require(msg.sender == owner, "Not owner");
assembly { sstore(_IMPL_SLOT, newImpl) }
}
}
Key insight: The proxy is ~20 lines of assembly. All the complexity is in the implementation โ thatโs why forgetting upgradeTo in V2 bricks the proxy forever. OpenZeppelinโs UUPSUpgradeable adds safety checks (implementation validation, rollback tests) that you should always use in production.
โ ๏ธ Common Mistakes
// โ WRONG: V2 doesn't inherit UUPSUpgradeable โ proxy bricked forever!
contract VaultV2 {
// Forgot to include upgrade capability
// No way to ever call upgradeTo again โ proxy is permanently stuck on V2
}
// โ
CORRECT: Every version MUST preserve upgrade capability
contract VaultV2 is UUPSUpgradeable, OwnableUpgradeable {
function _authorizeUpgrade(address) internal override onlyOwner {}
}
// โ WRONG: No access control on _authorizeUpgrade
contract VaultV1 is UUPSUpgradeable {
function _authorizeUpgrade(address) internal override {
// Anyone can upgrade the proxy to a malicious implementation!
}
}
// โ
CORRECT: Restrict who can authorize upgrades
contract VaultV1 is UUPSUpgradeable, OwnableUpgradeable {
function _authorizeUpgrade(address) internal override onlyOwner {}
}
// โ WRONG: Calling upgradeTo on the implementation directly
implementation.upgradeTo(newImpl); // Changes nothing โ impl has no proxy storage
// โ
CORRECT: Call upgradeTo through the proxy
VaultV1(address(proxy)).upgradeTo(newImpl); // Proxy's EIP-1967 slot gets updated
๐ก Concept: Beacon Proxy
Why this matters: When you have 100+ proxy instances that share the same logic, upgrading them individually is expensive and error-prone. Beacon proxies let you upgrade ALL instances in a single transaction. โจ
Defined in BeaconProxy.sol
How it works:
Multiple proxy instances share a single upgrade beacon that points to the implementation. Upgrading the beacon upgrades ALL proxies simultaneously.
Proxy A โโ Beacon โโ Implementation V1
Proxy B โโ Beacon โโ Implementation V1
Proxy C โโ Beacon โโ Implementation V1
After beacon update:
Proxy A โโ Beacon โโ Implementation V2
Proxy B โโ Beacon โโ Implementation V2
Proxy C โโ Beacon โโ Implementation V2
๐๏ธ DeFi use case:
Beacon proxies are ideal when you have many instances sharing the same logic. For example, a protocol with 100+ token wrappers could deploy each as a beacon proxy โ upgrading the beaconโs implementation address upgrades all instances atomically in a single SSTORE.
Note: Aave V3โs aTokens use a similar concept but with individual transparent-style proxies (
InitializableImmutableAdminUpgradeabilityProxy), not a shared beacon. Each aToken is upgraded individually viaPoolConfigurator.updateAToken(), which can be batched in a single governance transaction but still requires N separate proxy storage writes.
๐ Trade-offs:
| Aspect | Pro/Con |
|---|---|
| Batch upgrades | โ Pro โ Upgrade many instances in one tx |
| Gas efficiency | โ Pro โ Single upgrade vs many |
| Flexibility | โ Con โ All instances must use same implementation |
๐ก Concept: Diamond Pattern (EIP-2535) โ Awareness
What it is:
The Diamond pattern allows a single proxy to delegate to multiple implementation contracts (called โfacetsโ). Each function selector routes to its specific facet.
๐๏ธ Used by:
- LI.FI protocol (cross-chain aggregator)
- Some larger protocols with complex modular architectures
๐ Trade-off:
| Aspect | Pro/Con | Details |
|---|---|---|
| Modularity | โ Pro | Split 100+ functions across domains |
| Complexity | โ Con | Significantly more complex |
| Security risk | โ ๏ธ Warning | LI.FI exploit (July 2024, $10M) caused by facet validation bug |
Recommendation: For most DeFi protocols, UUPS or Transparent Proxy is sufficient. Diamond is worth knowing about but rarely needed. Complexity is a security risk.
๐ DeFi Pattern Connection
Which proxy pattern do real protocols use?
| Protocol | Pattern | Why |
|---|---|---|
| Aave V3 | Transparent (admin-immutable) | Transparent for core contracts; aTokens use individual proxies upgraded via PoolConfigurator (batchable in one governance tx) |
| Compound V3 (Comet) | Custom proxy | Immutable implementation with configurable parameters โ minimal proxy overhead |
| Uniswap V4 | UUPS (periphery) | Core PoolManager is immutable; only periphery uses UUPS for flexibility |
| MakerDAO | Custom delegation | delegatecall-based module system predating EIP standards |
| OpenSea (Seaport) | Immutable | No proxy at all โ designed to be replaced, not upgraded |
| Morpho Blue | Immutable | Intentionally non-upgradeable for trust minimization |
The trend in 2025+: Many new protocols are choosing immutable cores with upgradeable periphery. The core logic (AMM math, lending logic) is deployed once and never changed โ trust minimization. Only the parts that need flexibility (fee parameters, routing, UI adapters) use proxies.
Why this matters for DeFi:
- Protocol trust โ Users must trust that upgradeable contracts wonโt rug them. Immutable contracts with governance-controlled parameters are the emerging pattern
- Composability โ Other protocols integrating with yours need to know: will the interface change? Proxies make this uncertain
- Audit scope โ Every upgradeable contract doubles the audit surface (current + all possible future implementations)
๐ผ Job Market Context
What DeFi teams expect you to know:
-
โWhen would you use UUPS vs Transparent vs no proxy at all?โ
- Good answer: โUUPS for new deployments, Transparent for legacy, no proxy for trust-minimized core logicโ
- Great answer: โIt depends on the trust model. For core protocol logic that handles user funds, Iโd argue for immutable contracts โ users shouldnโt trust that an upgrade wonโt change the rules. For periphery (routers, adapters, fee modules), UUPS gives flexibility with lower gas overhead than Transparent. Beacon makes sense when you have many instances of the same contract (e.g., token vaults, lending pools) and want atomic upgrades. Note that Aave V3โs aTokens actually use individual transparent-style proxies upgraded via governance, not a shared beacon. Iโd avoid Diamond unless the protocol truly needs 100+ functions split across domainsโ
-
โWhatโs the biggest risk with upgradeable contracts?โ
- Good answer: โStorage collisions and uninitialized proxiesโ
- Great answer: โThree categories: (1) Storage collisions โ silent data corruption when layout changes, caught with
forge inspectin CI. (2) Initialization attacks โ front-runninginitialize()calls to take ownership. (3) Trust risk โ governance or multisig can change the implementation, which means users are trusting the upgrade authority, not just the code. The best mitigation is timelocked upgrades with on-chain governanceโ
Interview Red Flags:
- ๐ฉ Not knowing the difference between UUPS and Transparent proxy
- ๐ฉ Suggesting Diamond pattern for a simple protocol (over-engineering)
- ๐ฉ Not mentioning storage layout risks when discussing upgrades
- ๐ฉ Not considering the trust implications of upgradeability
Pro tip: In interviews, discussing the trade-off between upgradeability and trust minimization shows protocol design maturity. Saying โIโd make the core immutable and only use proxies for peripheryโ is a strong signal.
๐ก Storage Layout and Initializers
๐ก Concept: Storage Layout Compatibility
Why this matters: The #1 risk with proxy upgrades is storage collisions. Audius governance takeover exploit ($6M+ at risk) was caused by storage layout mismatch. This is silent, catastrophic, and happens at deploymentโnot caught by tests unless you specifically check.
How Solidity assigns storage:
Solidity assigns storage slots sequentially. If V2 adds a variable before existing ones, every subsequent slot shifts, corrupting data.
// V1
contract VaultV1 {
uint256 public totalSupply; // slot 0
mapping(address => uint256) balances; // slot 1
}
// โ V2 โ WRONG: inserts before existing variables
contract VaultV2 {
address public owner; // slot 0 โ COLLISION with totalSupply!
uint256 public totalSupply; // slot 1 โ COLLISION with balances!
mapping(address => uint256) balances; // slot 2
}
// โ
V2 โ CORRECT: append new variables after existing ones
contract VaultV2 {
uint256 public totalSupply; // slot 0 (same)
mapping(address => uint256) balances; // slot 1 (same)
address public owner; // slot 2 (new, appended)
}
Storage gaps:
To allow future inheritance changes, reserve empty slots. Gap size = target total slots - number of state variables in the contract. With a target of 50 total slots:
contract VaultV1 is Initializable {
uint256 public totalSupply; // slot 0
mapping(address => uint256) balances; // slot 1
uint256[48] private __gap; // โ
50 - 2 state vars = 48 gap slots (total 50 slots)
}
contract VaultV2 is Initializable {
uint256 public totalSupply; // slot 0 (same)
mapping(address => uint256) balances; // slot 1 (same)
address public owner; // slot 2 (new โ added 1 variable)
uint256[47] private __gap; // โ
48 - 1 new var = 47 gap slots (total still 50 slots)
}
Gap math rule: When adding N new state variables, reduce
__gapsize by N. Each variable occupies one slot (evenuint128โ packed structs are the exception, but itโs safer to count full slots). Always verify withforge inspect.
forge inspect for storage layout:
# View storage layout of a contract
forge inspect src/VaultV1.sol:VaultV1 storage-layout
# Compare two versions (catch collisions before upgrade)
forge inspect src/VaultV1.sol:VaultV1 storage-layout > v1-layout.txt
forge inspect src/VaultV2.sol:VaultV2 storage-layout > v2-layout.txt
diff v1-layout.txt v2-layout.txt
โก Common pitfall: Changing the inheritance order. If V1 inherits
A, Band V2 inheritsB, A, the storage layout changes even if no variables were added. Always maintain inheritance order.
๐ Deep dive: Foundry Storage Check Tool automates collision detection in CI/CD. RareSkills - OpenZeppelin Foundry Upgrades covers the OZ Foundry upgrades plugin. Runtime Verification - Foundry Upgradeable Contracts provides practical verification patterns.
๐ Modern Alternative: OpenZeppelin V5 introduced ERC-7201 (
@custom:storage-location) as a successor to__gappatterns. It uses namespaced storage at deterministic slots, eliminating the need for manual gap management. Worth exploring for new projects.
๐ก Concept: Initializers vs Constructors
The problem:
Constructors donโt work with proxiesโthe constructor runs on the implementation contract, not the proxy. The proxyโs storage is never initialized. โ
The solution:
Replace constructors with initialize() functions that can only be called once:
import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol";
contract VaultV1 is Initializable, OwnableUpgradeable {
uint256 public totalSupply;
/// @custom:oz-upgrades-unsafe-allow constructor
constructor() {
_disableInitializers(); // โ
Prevent implementation from being initialized
}
function initialize(address _owner) public initializer {
__Ownable_init(_owner);
}
}
โ ๏ธ The uninitialized proxy attack:
If initialize() can be called by anyone (or called again), an attacker can take ownership. Real exploits:
- Wormhole bridge initialization attack ($10M+ at risk, caught before exploit)
Protection mechanisms:
- โ
initializermodifier: prevents re-initialization - โ
reinitializer(n)modifier: allows controlled version-bumped re-initialization for upgrades that need to set new state - โ
_disableInitializers()in constructor: prevents someone from initializing the implementation contract directly
โก Common pitfall: Deploying a proxy and forgetting to call
initialize()in the same transaction. An attacker can front-run and call it first. Use a factory pattern or atomic deploy+initialize.
๐ DeFi Pattern Connection
Where storage and initialization bugs caused real exploits:
-
Storage Collision โ Audius ($6M+ at risk, 2022):
- Governance proxy upgraded with mismatched storage layout
- Attacker exploited the corrupted storage to pass governance proposals
- Root cause: inheritance order changed between versions
- Lesson: Always verify storage layout with
forge inspectbefore upgrading
-
Uninitialized Proxy โ Wormhole ($10M+ at risk, 2022):
- Implementation contract was not initialized after deployment
- Attacker could call
initialize()on the implementation directly - Fixed before exploitation, discovered through bug bounty
- Lesson: Always call
_disableInitializers()in the constructor
-
Initializer Re-entrancy โ Parity Wallet ($150M locked, 2017):
- The
initWallet()function could be called by anyone on the library contract - Attacker became owner of the library, then called
selfdestruct - All wallets using the library lost access to their funds forever
- Lesson: The original โwhy initializers matterโ incident
- The
-
Storage Gap Miscalculation:
- Common audit finding: adding a variable but not reducing the
__gapby the right amount - With packed structs, a single
uint128variable consumes a full slot in the gap - Lesson: Count slots, not variables. Use
forge inspectto verify
- Common audit finding: adding a variable but not reducing the
โ ๏ธ Common Mistakes
// โ Mistake 1: Changing inheritance order
contract V1 is Initializable, OwnableUpgradeable, PausableUpgradeable { ... }
contract V2 is Initializable, PausableUpgradeable, OwnableUpgradeable { ... }
// Storage layout CHANGED even though same variables exist!
// โ Mistake 2: Not protecting the implementation
contract VaultV1 is UUPSUpgradeable {
// Missing: constructor() { _disableInitializers(); }
// Anyone can call initialize() on the implementation contract directly
}
// โ Mistake 3: Using constructor logic in upgradeable contracts
contract VaultV1 is UUPSUpgradeable {
address public owner;
constructor() {
owner = msg.sender; // This sets owner on the IMPLEMENTATION, not the proxy!
}
}
// โ Mistake 4: Removing immutable variables between versions
contract V1 { uint256 public immutable fee = 100; } // NOT in storage
contract V2 { uint256 public fee = 100; } // IN storage at slot 0 โ collision!
๐ผ Job Market Context
What DeFi teams expect you to know:
-
โHow do you ensure storage layout compatibility between versions?โ
- Good answer: โAppend-only variables, storage gaps,
forge inspectโ - Great answer: โI run
forge inspecton both versions and diff the layouts before any upgrade. In CI, I use the foundry-storage-check tool to automatically catch layout regressions. I maintain__gaparrays sized so total slots stay constant, and I never change inheritance order. For complex upgrades, I write a fork test that deploys the new implementation against the live proxy state and verifies all existing data reads correctlyโ
- Good answer: โAppend-only variables, storage gaps,
-
โWalk me through a safe upgrade processโ
- Good answer: โDeploy new implementation, verify storage layout, upgrade proxyโ
- Great answer: โFirst, I diff storage layouts with
forge inspect. Then I deploy the new implementation and write a fork test that: (1) forks mainnet with the live proxy, (2) upgrades to the new implementation, (3) verifies all existing state reads correctly, (4) tests new functionality. Only after the fork test passes do I prepare the governance proposal or multisig transaction. For UUPS, I also verify the new implementation has_authorizeUpgradeโ without it, the proxy becomes permanently non-upgradeableโ
-
โWhatโs the uninitialized proxy attack?โ
- This is a common interview question. Know the Wormhole and Parity examples, and explain the three protections:
initializermodifier,_disableInitializers(), and atomic deploy+initialize
- This is a common interview question. Know the Wormhole and Parity examples, and explain the three protections:
Interview Red Flags:
- ๐ฉ Not knowing about storage layout compatibility
- ๐ฉ Forgetting
_disableInitializers()in implementation constructors - ๐ฉ Not mentioning
forge inspector automated layout checking - ๐ฉ Treating upgradeable and non-upgradeable contracts identically
Pro tip: Storage layout bugs are the #1 finding in upgrade audits. Being able to explain forge inspect storage-layout, __gap patterns, and inheritance ordering shows youโve actually worked with upgradeable contracts in production.
๐ฏ Build Exercise: Proxy Patterns
Workspace: workspace/src/part1/module6/ โ starter files: UUPSVault.sol, UninitializedProxy.sol, StorageCollision.sol, BeaconProxy.sol, tests: UUPSVault.t.sol, UninitializedProxy.t.sol, StorageCollision.t.sol, BeaconProxy.t.sol
Note: Exercise folders are numbered by difficulty progression:
- exercise1-uninitialized-proxy (simplest โ attack demonstration)
- exercise2-storage-collision (intermediate โ storage layout)
- exercise3-beacon-proxy (intermediate โ beacon pattern)
- exercise4-uups-vault (advanced โ full UUPS implementation)
Exercise 1: UUPS upgradeable vault
-
Deploy a UUPS-upgradeable ERC-20 vault:
- V1: basic deposit/withdraw
- Include storage gap:
uint256[50] private __gap;
-
Upgrade to V2:
- Add withdrawal fee:
uint256 public withdrawalFeeBps; - Reduce gap:
uint256[49] private __gap; - Add
initializeV2(uint256 _fee)withreinitializer(2)
- Add withdrawal fee:
-
Verify:
- โ Storage persists across upgrade (deposits intact)
- โ V2 logic is active (fee is charged)
- โ Old deposits can still withdraw (with fee)
-
Use
forge inspectto verify storage layout compatibility
Exercise 2: Uninitialized proxy attack
- Deploy a transparent proxy with an implementation that has
initialize(address owner) - Show the attack: anyone can call
initialize()and become owner โ - Fix with
initializermodifier โ - Show that calling
initialize()again reverts - Add
_disableInitializers()to implementation constructor
Exercise 3: Storage collision demonstration
- Deploy V1 with
uint256 totalSupplyat slot 0, deposit 1000 tokens - Deploy V2 that inserts
address ownerbeforetotalSupplyโ - Upgrade the proxy to V2
- Read
ownerโit will contain the corruptedtotalSupplyvalue (1000 as an address) - Fix with correct append-only layout โ
- Verify with
forge inspect storage-layout
Exercise 4: Beacon proxy pattern
- Deploy a beacon and 3 proxy instances (simulating 3 aToken-like contracts)
- Each proxy has different underlying tokens (USDC, DAI, WETH)
- Upgrade the beaconโs implementation (e.g., add a fee)
- Verify all 3 proxies now use the new logic โจ
- Show that upgrading once updated all instances
๐ฏ Goal: Understand proxy mechanics deeply enough to read Aave V3โs proxy architecture and deploy your own upgradeable contracts safely.
๐ Summary: Proxy Patterns
โ Covered:
- Proxy patterns โ Transparent, UUPS, Beacon, Diamond
- Storage layout โ append-only upgrades, storage gaps, collision detection
- Initializers โ replacing constructors, preventing re-initialization
- Security โ uninitialized proxies, storage collisions, real exploits
Key takeaway: Proxies enable upgradeability but introduce complexity. Storage layout compatibility is criticalโtest it with forge inspect before deploying upgrades.
๐ How to Study Production Proxy Architectures
When you encounter a proxy-based protocol (which is most of DeFi), hereโs how to navigate the code:
Step 1: Identify the proxy type On Etherscan, look for the โRead as Proxyโ tab. This tells you:
- The proxy address (what users interact with)
- The implementation address (where the logic lives)
- The admin address (who can upgrade)
Step 2: Read the implementation, not the proxy
The proxy itself is usually minimal (just fallback + DELEGATECALL). All the interesting logic is in the implementation. On Etherscan, click โRead as Proxyโ to see the implementationโs ABI.
Step 3: Check the storage layout For Aave V3, look at how they organize storage:
Pool.sol inherits:
โโโ PoolStorage (defines all state variables)
โโโ VersionedInitializable (custom initializer)
โโโ IPool (interface)
The pattern: one base contract holds ALL storage, preventing inheritance conflicts.
Step 4: Trace the upgrade path Look for:
upgradeTo()orupgradeToAndCall()โ who can call it?- Is there a timelock? A multisig?
- What governance process is required?
- Aave V3: Governed by Aave Governance V3 with timelock
Step 5: Verify the initializer chain Check that every base contractโs initializer is called:
function initialize(IPoolAddressesProvider provider) external initializer {
__Pool_init(provider); // Calls PoolStorage init
// All parent initializers must be called
}
Donโt get stuck on: The proxy contractโs assembly code. Once you understand the pattern (it delegates everything), focus entirely on the implementation.
๐ Cross-Module Concept Links
Backward references (โ concepts from earlier modules):
| Module | Concept | How It Connects |
|---|---|---|
| โ M1 Modern Solidity | Custom errors | Work normally in upgradeable contracts โ selector-based, no storage impact |
| โ M1 Modern Solidity | UDVTs | Cross proxy boundaries safely โ type wrapping has zero storage footprint |
| โ M1 Modern Solidity | immutable variables | Critical: not in storage โ removing immutables between versions causes slot shifts |
| โ M2 EVM Changes | Transient storage | TSTORE/TLOAD works through DELEGATECALL โ proxy and implementation share transient context |
| โ M3 Token Approvals | EIP-2612 permits | DOMAIN_SEPARATOR uses address(this) = proxy address (correct), not implementation |
| โ M3 Token Approvals | Permit2 integration | Permit2 approvals target the proxy address โ survives implementation upgrades |
| โ M4 Account Abstraction | ERC-4337 wallets | SimpleAccount uses UUPS โ wallet is a proxy, enabling logic upgrades without address change |
| โ M5 Foundry | forge inspect | Primary tool for verifying storage layout compatibility before upgrades |
| โ M5 Foundry | Fork testing | Verify upgrades against live proxy state with vm.load for EIP-1967 slots |
Forward references (โ concepts youโll use later):
| Module | Concept | How It Connects |
|---|---|---|
| โ M7 Deployment | CREATE2 deployment | Deterministic proxy addresses across chains |
| โ M7 Deployment | Atomic deploy+init | Deployment scripts that deploy proxy and call initialize() in one transaction |
| โ M7 Deployment | Multi-chain consistency | Same proxy addresses on every chain via CREATE2 + same nonce |
Part 2 connections:
| Part 2 Module | Proxy Pattern | Application |
|---|---|---|
| M1: Token Mechanics | Beacon proxy | Rebasing tokens (like stETH) use proxy patterns for upgradeable accounting |
| M2: AMMs | Immutable core | Uniswap V4 PoolManager is immutable โ trust minimization for AMM math |
| M4: Lending | Transparent + individual proxies | Aave V3 uses Transparent for Pool, individual transparent-style proxies for aTokens (upgraded via PoolConfigurator) |
| M4: Lending | Custom immutable | Compound V3 (Comet) uses custom proxy with immutable implementation |
| M5: Flash Loans | UUPS periphery | Flash loan routers behind UUPS for upgradeable routing logic |
| M6: Stablecoins | Timelock + proxy | Governance controls proxy upgrades via timelock โ upgrade authorization |
| M8: Security | Exploit patterns | Uninitialized proxies and storage collisions are top audit findings |
| M9: Integration | Full architecture | Capstone combines proxy deployment, initialization, and upgrade testing |
๐ Production Study Order
Study these proxy implementations in this order โ each builds on patterns from the previous:
| # | Repository | Why Study This | Key Files |
|---|---|---|---|
| 1 | OZ Proxy contracts | Clean reference implementations โ learn the standards | ERC1967Proxy.sol, TransparentUpgradeableProxy.sol |
| 2 | OZ UUPSUpgradeable | Understand UUPS internals โ _authorizeUpgrade, rollback test | UUPSUpgradeable.sol, Initializable.sol |
| 3 | Compound V3 (Comet) | Custom immutable proxy โ simpler than Aave, different philosophy | Comet.sol, CometConfiguration.sol |
| 4 | Aave V3 Pool | Full production proxy architecture โ Transparent for core, individual transparent-style proxies for aTokens | Pool.sol, PoolStorage.sol, AToken.sol |
| 5 | Aave V3 aToken proxies | Individual transparent-style proxies for aTokens โ 100+ instances, upgraded via PoolConfigurator | AToken.sol, VersionedInitializable.sol |
| 6 | Gnosis Safe | EIP-1167 minimal proxy + singleton pattern | Safe.sol, SafeProxy.sol โ most-deployed proxy in DeFi |
| 7 | ERC-4337 SimpleAccount | UUPS for smart wallets โ proxy as account abstraction pattern | SimpleAccount.sol, BaseAccount.sol |
Reading strategy: Start with OZ to learn the canonical proxy patterns, then study Compoundโs intentionally different approach (immutable implementation). Move to Aave for the most complex production proxy architecture youโll encounter. Finish with ERC-4337 to see UUPS applied to a completely different domain โ smart wallets instead of DeFi protocols.
๐ Resources
Proxy Standards
- EIP-1967 โ standard proxy storage slots (implementation, admin, and beacon slots)
- EIP-1822 (UUPS) โ universal upgradeable proxy standard
- EIP-2535 (Diamond) โ multi-facet proxy
OpenZeppelin Implementations
Production Examples
- Aave V3 Proxy Architecture โ individual transparent-style proxies for aTokens, initialization patterns
- Compound V3 Configurator โ custom proxy with immutable implementation
Security Resources
- OpenZeppelin Proxy Upgrade Guide โ best practices
- Audius governance takeover postmortem โ storage collision exploit
- Wormhole uninitialized proxy โ initialization attack
Tools
- Foundry storage layout โ
forge inspect storage-layout - OpenZeppelin Upgrades Plugin โ automated layout checking
Navigation: โ Module 5: Foundry | Module 7: Deployment โ