Skip to content

Proxy Contracts & Upgradeability

Smart Contracts on Ethereum are immutable by design—once deployed, the bytecode cannot change. Proxy contracts sidestep this by separating storage from logic: the proxy holds state and forwards calls to a separate implementation contract that can be swapped. This pattern enables bug fixes and feature upgrades, but introduces trust assumptions and attack surfaces that have cost protocols hundreds of millions of dollars.

TL;DR

  • Proxies use delegatecall to execute implementation code in the proxy's storage context
  • Three main patterns: Transparent Proxy, UUPS, and Beacon Proxy
  • EIP-1967 standardizes storage slot locations to prevent collisions
  • Uninitialized implementation contracts are a critical vulnerability—attackers can hijack them
  • Storage layout must be preserved across upgrades or state corruption occurs
  • UUPS is more gas-efficient but can be permanently bricked if upgrade logic is removed
  • Upgradeability means trusting whoever controls the upgrade key

How Proxies Work

A proxy contract receives all calls and delegates them to an implementation contract using the EVM's delegatecall opcode. The key property of delegatecall: code executes in the caller's context, meaning the implementation's logic operates on the proxy's storage.

sequenceDiagram
    participant User
    participant Proxy
    participant Implementation

    User->>Proxy: call transfer(to, amount)
    Note over Proxy: Storage lives here
    Proxy->>Implementation: delegatecall transfer(to, amount)
    Note over Implementation: Logic lives here
    Implementation-->>Proxy: Execute in proxy's context
    Proxy-->>User: Return result

Figure 1: Delegatecall executes implementation code against proxy storage.

The proxy stores the implementation address in a specific storage slot. To upgrade, an authorized party updates this address to point to new implementation code. All existing storage remains intact.


Proxy Patterns

PATTERN UPGRADE LOGIC LOCATION GAS COST COMPLEXITY USE CASE
Transparent Proxy contract Higher Medium General purpose
UUPS Implementation contract Lower Medium Gas-sensitive protocols
Beacon Beacon contract Medium Higher Multiple identical proxies
Diamond (EIP-2535) Facet contracts Variable High Modular systems

Transparent Proxy

The proxy checks msg.sender on every call. If the caller is the admin, the call targets proxy functions (like upgradeTo). If not, the call delegates to the implementation.

Trade-offs:

  • ☑ Clear separation between admin and user functions
  • ☑ Implementation cannot accidentally expose upgrade functions
  • ☒ Gas overhead on every call for admin check
  • ☒ Requires separate ProxyAdmin contract

UUPS (Universal Upgradeable Proxy Standard)

Defined in ERC-1822, UUPS moves upgrade logic into the implementation contract itself. The proxy is minimal—just storage and delegation.

Trade-offs:

  • ☑ Cheaper deployment and calls (no admin check in proxy)
  • ☑ OpenZeppelin's recommended pattern
  • ☒ Implementation must inherit upgrade logic or proxy becomes permanently frozen
  • ☒ Larger implementation bytecode (includes upgrade functions)

Beacon Proxy

Multiple proxies point to a single Beacon contract that holds the implementation address. Updating the Beacon upgrades all associated proxies simultaneously.

Trade-offs:

  • ☑ Efficient for managing many identical proxy instances
  • ☑ Single transaction upgrades all proxies
  • ☒ Added complexity and indirection
  • ☒ Less commonly used

EIP-1967: Standard Storage Slots

The implementation address must be stored somewhere in the proxy. If stored in slot 0 or 1, it would collide with implementation state variables. EIP-1967 defines specific slots derived from hashing known strings:

SLOT PURPOSE DERIVATION SLOT VALUE
Implementation keccak256('eip1967.proxy.implementation') - 1 0x360894...
Admin keccak256('eip1967.proxy.admin') - 1 0xb531...
Beacon keccak256('eip1967.proxy.beacon') - 1 0xa3f0ad...

These slots are astronomically unlikely to collide with compiler-assigned storage (2^256 possible slots). Block explorers use these standardized slots to identify proxy contracts and link to their implementations.


What Can Go Wrong

Uninitialized Implementation Contracts

The implementation contract exists as a standalone deployment. If its initialize function isn't called (or can be called again), an attacker can:

  1. Call initialize on the implementation directly
  2. Become the owner/admin
  3. Upgrade the proxy to malicious code or selfdestruct

Real exploits:

  • △ Ronin Bridge (2024): Upgrade skipped initializing v3, leaving threshold at 0—$12M stolen (see Bridges for context on bridge vulnerabilities)
  • △ Delta Prime (2024): Storage collision + forgotten init function exploited
  • △ Pike Finance (2024): Upgrade moved initialized flag to wrong slot, enabling reinitialization

Mitigation: Call _disableInitializers() in the implementation's constructor, or initialize immediately after deployment.

Storage Collisions

When upgrading, the new implementation must preserve the exact storage layout of the previous version. Adding, removing, or reordering state variables corrupts existing data.

// V1
contract TokenV1 {
    address owner;      // slot 0
    uint256 totalSupply; // slot 1
}

// V2 - WRONG: inserted variable shifts slots
contract TokenV2 {
    address owner;      // slot 0
    address newAdmin;   // slot 1 ← totalSupply data now interpreted as address
    uint256 totalSupply; // slot 2
}

// V2 - CORRECT: append only
contract TokenV2 {
    address owner;      // slot 0
    uint256 totalSupply; // slot 1
    address newAdmin;   // slot 2 ← new variables at end
}

Function Selector Clashing

If the proxy and implementation have functions with the same 4-byte selector, calls may route incorrectly. Transparent proxies handle this by segregating admin calls; UUPS implementations must avoid selector collisions manually.

Bricked UUPS Proxies

If a UUPS implementation is upgraded to a contract that doesn't include upgrade logic (or has a bug in _authorizeUpgrade), the proxy is permanently frozen. There's no recovery—the upgrade function no longer exists.

Centralization Risk

Upgradeability requires trusting the upgrade key holder. Common mitigations:

  • Multisig admin (e.g., Gnosis Safe)
  • Timelock delays (governance can review before execution)
  • Renouncing upgrade capability after stabilization

See Multisigs & Timelocks for best practices on securing upgrade controls.


Identifying Proxy Contracts

When analyzing a contract:

  1. Check if storage slot 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc contains an address
  2. Look for delegatecall in the fallback function
  3. Check Etherscan's "Read as Proxy" tab
  4. Review who controls the admin/upgrade functions
# Check implementation slot using cast
cast storage <proxy_address> 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc

References

Changelog

DATE AUTHOR NOTES
2026-01-04 Artificial. Generated by robots.
2026-01-08 Denizen. Reviewed, edited, and curated by humans.