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
delegatecallto 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:
- Call
initializeon the implementation directly - Become the owner/admin
- 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
initializedflag 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:
- Check if storage slot
0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbccontains an address - Look for
delegatecallin the fallback function - Check Etherscan's "Read as Proxy" tab
- Review who controls the admin/upgrade functions
# Check implementation slot using cast
cast storage <proxy_address> 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc
References
- EIP-1967: Proxy Storage Slots
- ERC-1822: Universal Upgradeable Proxy Standard (UUPS)
- OpenZeppelin Proxy Documentation
- RareSkills: EIP 1967 Storage Slots for Proxies
- RareSkills: UUPS Proxy Pattern
- Three Sigma: Upgradeable Contract Security Risks
- Cyfrin: Guide to Upgradeable Proxy Patterns
- CertiK: Upgradeable Proxy Security Best Practices
Changelog
| DATE | AUTHOR | NOTES |
|---|---|---|
| 2026-01-04 | Artificial. | Generated by robots. |
| 2026-01-08 | Denizen. | Reviewed, edited, and curated by humans. |