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.sender is 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 ProxyAdmin contract 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 ProxyAdmin is 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() (or upgradeToAndCall()) on the proxy, which delegatecalls 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 _authorizeUpgrade permanently 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 call upgradeTo on it directly, which (because address(this) then refers to the implementation, not the proxy) can selfdestruct the 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-upgradeability or @openzeppelin/upgrades-core's validateUpgrade for 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 an upgradeTo() 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.
  • diamondCut updates 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.
  • diamondCut is 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 diamondCut can 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:

PatternBest forWorst for
TransparentSingle contract, occasional upgrades, mature governanceMany similar instances; tightest gas budgets
UUPSSingle contract, leaner gas, willingness to maintain upgrade logic per implementationTeams without rigorous upgrade-safety review for every release
BeaconN similar instances upgraded together (token vaults, strategy pods, factories)Single-contract protocols
DiamondVery large protocols hitting the 24KB limit, with modular logicSmaller 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.
  • selfdestruct reachable 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 initialize callable by anyone.

Each of these is examined more closely in the subsections that follow.