Keyboard shortcuts

Press โ† or โ†’ to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Module 6: Proxy Patterns & Upgradeability

Difficulty: Advanced

Estimated reading time: ~25 minutes | Exercises: ~6-8 hours

๐Ÿ“š Table of Contents

Proxy Fundamentals

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 impl at 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:

  1. The admin from accidentally calling implementation functions
  2. Function selector clashes between proxy admin functions and implementation functions

๐Ÿ“Š Trade-offs:

AspectPro/ConDetails
Mental modelโœ… ProSimple to understand
Admin safetyโœ… ProAdmin canโ€™t accidentally interact with implementation
Gas costโŒ ConEvery call checks msg.sender == admin (~100 gas overhead)
Admin limitationโŒ ConAdmin address can never interact with implementation
DeploymentโŒ ConExtra 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:

FeatureUUPS โœ…Transparent
Gas costCheaper (no admin check)Higher (~100 gas/call)
FlexibilityCustom upgrade logic per versionFixed upgrade logic
DeploymentSimpler (no ProxyAdmin)Requires ProxyAdmin
RiskCan brick if upgrade logic is missingSafer 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 via PoolConfigurator.updateAToken(), which can be batched in a single governance transaction but still requires N separate proxy storage writes.

๐Ÿ“Š Trade-offs:

AspectPro/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:

AspectPro/ConDetails
Modularityโœ… ProSplit 100+ functions across domains
ComplexityโŒ ConSignificantly more complex
Security riskโš ๏ธ WarningLI.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?

ProtocolPatternWhy
Aave V3Transparent (admin-immutable)Transparent for core contracts; aTokens use individual proxies upgraded via PoolConfigurator (batchable in one governance tx)
Compound V3 (Comet)Custom proxyImmutable implementation with configurable parameters โ€” minimal proxy overhead
Uniswap V4UUPS (periphery)Core PoolManager is immutable; only periphery uses UUPS for flexibility
MakerDAOCustom delegationdelegatecall-based module system predating EIP standards
OpenSea (Seaport)ImmutableNo proxy at all โ€” designed to be replaced, not upgraded
Morpho BlueImmutableIntentionally 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:

  1. Protocol trust โ€” Users must trust that upgradeable contracts wonโ€™t rug them. Immutable contracts with governance-controlled parameters are the emerging pattern
  2. Composability โ€” Other protocols integrating with yours need to know: will the interface change? Proxies make this uncertain
  3. Audit scope โ€” Every upgradeable contract doubles the audit surface (current + all possible future implementations)

๐Ÿ’ผ Job Market Context

What DeFi teams expect you to know:

  1. โ€œ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โ€
  2. โ€œ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 inspect in CI. (2) Initialization attacks โ€” front-running initialize() 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 __gap size by N. Each variable occupies one slot (even uint128 โ€” packed structs are the exception, but itโ€™s safer to count full slots). Always verify with forge 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, B and V2 inherits B, 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 __gap patterns. 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:

Protection mechanisms:

  1. โœ… initializer modifier: prevents re-initialization
  2. โœ… reinitializer(n) modifier: allows controlled version-bumped re-initialization for upgrades that need to set new state
  3. โœ… _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:

  1. 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 inspect before upgrading
  2. 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
  3. 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
  4. Storage Gap Miscalculation:

    • Common audit finding: adding a variable but not reducing the __gap by the right amount
    • With packed structs, a single uint128 variable consumes a full slot in the gap
    • Lesson: Count slots, not variables. Use forge inspect to verify

โš ๏ธ 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:

  1. โ€œHow do you ensure storage layout compatibility between versions?โ€

    • Good answer: โ€œAppend-only variables, storage gaps, forge inspectโ€
    • Great answer: โ€œI run forge inspect on 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 __gap arrays 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โ€
  2. โ€œ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โ€
  3. โ€œWhatโ€™s the uninitialized proxy attack?โ€

    • This is a common interview question. Know the Wormhole and Parity examples, and explain the three protections: initializer modifier, _disableInitializers(), and atomic deploy+initialize

Interview Red Flags:

  • ๐Ÿšฉ Not knowing about storage layout compatibility
  • ๐Ÿšฉ Forgetting _disableInitializers() in implementation constructors
  • ๐Ÿšฉ Not mentioning forge inspect or 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

  1. Deploy a UUPS-upgradeable ERC-20 vault:

    • V1: basic deposit/withdraw
    • Include storage gap: uint256[50] private __gap;
  2. Upgrade to V2:

    • Add withdrawal fee: uint256 public withdrawalFeeBps;
    • Reduce gap: uint256[49] private __gap;
    • Add initializeV2(uint256 _fee) with reinitializer(2)
  3. Verify:

    • โœ… Storage persists across upgrade (deposits intact)
    • โœ… V2 logic is active (fee is charged)
    • โœ… Old deposits can still withdraw (with fee)
  4. Use forge inspect to verify storage layout compatibility

Exercise 2: Uninitialized proxy attack

  1. Deploy a transparent proxy with an implementation that has initialize(address owner)
  2. Show the attack: anyone can call initialize() and become owner โŒ
  3. Fix with initializer modifier โœ…
  4. Show that calling initialize() again reverts
  5. Add _disableInitializers() to implementation constructor

Exercise 3: Storage collision demonstration

  1. Deploy V1 with uint256 totalSupply at slot 0, deposit 1000 tokens
  2. Deploy V2 that inserts address owner before totalSupply โŒ
  3. Upgrade the proxy to V2
  4. Read ownerโ€”it will contain the corrupted totalSupply value (1000 as an address)
  5. Fix with correct append-only layout โœ…
  6. Verify with forge inspect storage-layout

Exercise 4: Beacon proxy pattern

  1. Deploy a beacon and 3 proxy instances (simulating 3 aToken-like contracts)
  2. Each proxy has different underlying tokens (USDC, DAI, WETH)
  3. Upgrade the beaconโ€™s implementation (e.g., add a fee)
  4. Verify all 3 proxies now use the new logic โœจ
  5. 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() or upgradeToAndCall() โ€” 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.

Backward references (โ† concepts from earlier modules):

ModuleConceptHow It Connects
โ† M1 Modern SolidityCustom errorsWork normally in upgradeable contracts โ€” selector-based, no storage impact
โ† M1 Modern SolidityUDVTsCross proxy boundaries safely โ€” type wrapping has zero storage footprint
โ† M1 Modern Solidityimmutable variablesCritical: not in storage โ€” removing immutables between versions causes slot shifts
โ† M2 EVM ChangesTransient storageTSTORE/TLOAD works through DELEGATECALL โ€” proxy and implementation share transient context
โ† M3 Token ApprovalsEIP-2612 permitsDOMAIN_SEPARATOR uses address(this) = proxy address (correct), not implementation
โ† M3 Token ApprovalsPermit2 integrationPermit2 approvals target the proxy address โ€” survives implementation upgrades
โ† M4 Account AbstractionERC-4337 walletsSimpleAccount uses UUPS โ€” wallet is a proxy, enabling logic upgrades without address change
โ† M5 Foundryforge inspectPrimary tool for verifying storage layout compatibility before upgrades
โ† M5 FoundryFork testingVerify upgrades against live proxy state with vm.load for EIP-1967 slots

Forward references (โ†’ concepts youโ€™ll use later):

ModuleConceptHow It Connects
โ†’ M7 DeploymentCREATE2 deploymentDeterministic proxy addresses across chains
โ†’ M7 DeploymentAtomic deploy+initDeployment scripts that deploy proxy and call initialize() in one transaction
โ†’ M7 DeploymentMulti-chain consistencySame proxy addresses on every chain via CREATE2 + same nonce

Part 2 connections:

Part 2 ModuleProxy PatternApplication
M1: Token MechanicsBeacon proxyRebasing tokens (like stETH) use proxy patterns for upgradeable accounting
M2: AMMsImmutable coreUniswap V4 PoolManager is immutable โ€” trust minimization for AMM math
M4: LendingTransparent + individual proxiesAave V3 uses Transparent for Pool, individual transparent-style proxies for aTokens (upgraded via PoolConfigurator)
M4: LendingCustom immutableCompound V3 (Comet) uses custom proxy with immutable implementation
M5: Flash LoansUUPS peripheryFlash loan routers behind UUPS for upgradeable routing logic
M6: StablecoinsTimelock + proxyGovernance controls proxy upgrades via timelock โ€” upgrade authorization
M8: SecurityExploit patternsUninitialized proxies and storage collisions are top audit findings
M9: IntegrationFull architectureCapstone combines proxy deployment, initialization, and upgrade testing

๐Ÿ“– Production Study Order

Study these proxy implementations in this order โ€” each builds on patterns from the previous:

#RepositoryWhy Study ThisKey Files
1OZ Proxy contractsClean reference implementations โ€” learn the standardsERC1967Proxy.sol, TransparentUpgradeableProxy.sol
2OZ UUPSUpgradeableUnderstand UUPS internals โ€” _authorizeUpgrade, rollback testUUPSUpgradeable.sol, Initializable.sol
3Compound V3 (Comet)Custom immutable proxy โ€” simpler than Aave, different philosophyComet.sol, CometConfiguration.sol
4Aave V3 PoolFull production proxy architecture โ€” Transparent for core, individual transparent-style proxies for aTokensPool.sol, PoolStorage.sol, AToken.sol
5Aave V3 aToken proxiesIndividual transparent-style proxies for aTokens โ€” 100+ instances, upgraded via PoolConfiguratorAToken.sol, VersionedInitializable.sol
6Gnosis SafeEIP-1167 minimal proxy + singleton patternSafe.sol, SafeProxy.sol โ€” most-deployed proxy in DeFi
7ERC-4337 SimpleAccountUUPS for smart wallets โ€” proxy as account abstraction patternSimpleAccount.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

OpenZeppelin Implementations

Production Examples

Security Resources

Tools


Navigation: โ† Module 5: Foundry | Module 7: Deployment โ†’