Proxy Patterns
Almost every upgradeable smart contract in production today uses one of four proxy patterns: Transparent, UUPS, Beacon, or Diamond (EIP-2535). Each works by separating the contract's address and storage (the proxy) from its executable logic (the implementation), and forwarding calls from the former to the latter via delegatecall. The patterns differ in who controls upgrades, where the upgrade logic lives, and how multiple implementations are managed.
The Shared Mechanism
All four patterns rely on the same EVM primitive: delegatecall. When a proxy receives a call, it executes the implementation's bytecode in the proxy's own storage and msg.sender context. This means:
- State variables read and written by the implementation live in the proxy's storage.
address(this)inside the implementation returns the proxy's address.msg.senderis the original caller, not the proxy.- The implementation contract itself, if called directly, runs against its own (usually empty) storage — which is why direct interaction with implementations is almost always a bug or an attack vector.
Every proxy pattern wraps this primitive with a different scheme for storing the implementation address and authorizing upgrades.
Transparent Proxy
The Transparent Proxy Pattern (TPP), introduced by OpenZeppelin, is the most widely deployed upgrade pattern in DeFi.
How it works:
- The proxy stores the implementation address and an admin address in two well-known storage slots (defined by EIP-1967).
- A separate
ProxyAdmincontract holds the admin role; only it can upgrade. - When the admin calls the proxy, only proxy-level functions (
upgradeTo,changeAdmin) are reachable; user calls are forwarded to the implementation. - When anyone else calls the proxy, all calls are forwarded to the implementation.
Strengths:
- Clear separation: admin sees admin functions, users see user functions.
- The most battle-tested pattern; tooling (
@openzeppelin/hardhat-upgrades,@openzeppelin/foundry-upgrades) is mature. - No function-selector ambiguity between admin and user functions, because the proxy dispatches based on the caller.
Weaknesses and audit notes:
- Slightly higher gas on every call (the caller-check is unavoidable).
- The
ProxyAdminis itself a single point of compromise; it must be held by a timelocked multisig. - The admin address cannot interact with the implementation normally, which can surprise integrators if the same multisig is used for protocol operations and upgrades.
- The proxy admin slot and implementation slot must follow EIP-1967; non-standard placements have produced findings.
UUPS (Universal Upgradeable Proxy Standard)
UUPS (EIP-1822) moves the upgrade logic into the implementation contract itself. The proxy is minimal; it just forwards. The current implementation exposes an upgradeTo(address) function (typically inherited from UUPSUpgradeable) that updates the implementation slot.
How it works:
- Proxy stores only the implementation address (EIP-1967 slot).
- The implementation must inherit upgrade logic and implement an
_authorizeUpgrade(address)hook that gates who can upgrade. - Upgrading requires calling
upgradeTo()(orupgradeToAndCall()) on the proxy, whichdelegatecalls into the implementation's upgrade logic.
Strengths:
- Smaller proxy → cheaper deployment and slightly cheaper calls than TPP.
- More flexible: each implementation can change its own upgrade authorization rules.
Weaknesses and audit notes:
- The upgrade ability lives in the implementation. A new implementation that omits or misimplements
_authorizeUpgradepermanently breaks upgradeability — or, worse, makes the proxy upgradeable by anyone. This is a real, recurring class of bug; see the OpenZeppelin advisory on UUPS proxies. - The implementation contract is itself callable directly. Without
_disableInitializers()in its constructor, an attacker can take ownership of the implementation, then callupgradeToon it directly, which (becauseaddress(this)then refers to the implementation, not the proxy) canselfdestructthe implementation and brick every proxy that points to it. This was the root cause of the Wormhole UUPS issue in 2022. - Any new implementation should be checked with
slither-check-upgradeabilityor@openzeppelin/upgrades-core'svalidateUpgradefor compatibility.
Beacon Proxy (EIP-1967 Beacon Pattern)
A Beacon Proxy shifts the implementation address into a separate, shared Beacon contract that many proxies read from. Upgrading the beacon upgrades every proxy that points to it, simultaneously.
How it works:
- Each proxy stores the address of a Beacon, not the implementation.
- The Beacon contract exposes an
implementation()getter and anupgradeTo()function. - On each call, the proxy reads
beacon.implementation()and delegates to that address.
Strengths:
- Atomic upgrades across many instances (e.g. all token vaults of a given type).
- Cleaner than tracking N independent UUPS proxies.
Weaknesses and audit notes:
- Extra SLOAD per call to fetch the implementation from the beacon — gas overhead.
- Beacon compromise upgrades every instance at once. This is by design but expands blast radius; the beacon's admin authorization deserves at least as much scrutiny as a single protocol owner.
- If the beacon address in the proxy is mutable (some implementations expose this), it becomes another upgrade vector that must be considered.
Diamond (EIP-2535)
The Diamond pattern allows a single proxy to delegate different function selectors to different implementation contracts ("facets"), and to add, replace, or remove facets via a diamondCut.
How it works:
- The proxy ("diamond") stores a mapping from function selectors to facet addresses.
- Calls are dispatched by selector to the appropriate facet via
delegatecall. diamondCutupdates the selector → facet mapping and can run an arbitrary initializer.- Storage is typically organized via Diamond Storage (deterministic, namespaced struct slots) to prevent collisions between facets.
Strengths:
- Bypasses the 24KB contract-size limit by splitting logic across facets.
- Granular upgrades: replace one facet without touching others.
- Useful for large, modular protocols.
Weaknesses and audit notes:
- Complexity. Diamonds are dramatically harder to audit than the other patterns. Tooling is less mature, mental models are less shared, and selector-by-selector dispatch invites subtle bugs.
- Facet storage collisions if Diamond Storage discipline is not enforced. Every facet that touches a storage variable must use a unique namespace (typically a keccak hash of a unique string), and any deviation can corrupt unrelated state.
diamondCutis the upgrade vector for everything. Its access control is the single most important guard in the system.- Initialization is per-cut, not per-deploy. Each
diamondCutcan run an initializer; missing or misordered initializers across cuts have produced findings. - Selector clashes between facets are possible but rare; tooling and explicit registries should be checked.
Choosing (or Recognizing) the Right Pattern
Auditors do not usually pick the pattern, but they should understand the trade-off space well enough to challenge a choice that does not fit the use case:
| Pattern | Best for | Worst for |
|---|---|---|
| Transparent | Single contract, occasional upgrades, mature governance | Many similar instances; tightest gas budgets |
| UUPS | Single contract, leaner gas, willingness to maintain upgrade logic per implementation | Teams without rigorous upgrade-safety review for every release |
| Beacon | N similar instances upgraded together (token vaults, strategy pods, factories) | Single-contract protocols |
| Diamond | Very large protocols hitting the 24KB limit, with modular logic | Smaller protocols; teams without significant audit budget |
A custom proxy pattern is always a finding-in-waiting. Unless there is a documented reason that none of the four standard patterns fits, the audit should challenge the decision to roll a custom one.
Common Anti-Patterns Across All Proxies
Regardless of the specific pattern, the following appear in audit after audit and are worth flagging on sight:
- Storage slots overlapping between proxy and implementation (covered in §4.12.2).
- Missing or unguarded initializers (§4.12.3).
- Function selector clashes between proxy admin functions and implementation functions (TPP solves this structurally; UUPS does not).
- Upgrader role held by a single EOA, with no timelock.
- No
_disableInitializers()in the implementation constructor for UUPS. selfdestructreachable from the implementation — even today, with the post-Cancun semantics, this can still brick proxies in specific circumstances.- Implementation contracts deployed but never initialized, leaving an unguarded
initializecallable by anyone.
Each of these is examined more closely in the subsections that follow.