How do upgradeable proxy contracts work? UUPS vs Transparent · Jumpstart Blockchain
How do upgradeable proxy contracts work? UUPS vs Transparent
A proxy holds storage and delegatecalls an implementation. Transparent proxies keep upgrade logic in the proxy; UUPS puts it in the implementation, which is cheaper but can brick upgrades if done wrong.
Deployed contract code can't be changed. An "upgradeable contract" is really two contracts: a proxy that users interact with and that holds all the storage and funds, and an implementation that holds the logic. Upgrading means pointing the proxy at a new implementation. UUPS and Transparent are two ways of deciding where the upgrade function lives and who is allowed to call it.
The mechanism: delegatecall
The proxy has almost no functions of its own. Its fallback forwards every call to the implementation with delegatecall, which runs the implementation's code in the proxy's context: same storage, same msg.sender, same msg.value, same balance.
So the implementation's state variables are really slots in the proxy's storage. That has three consequences:
Constructors don't work. A constructor runs in the implementation's own context at deploy time and never touches the proxy's storage. Upgradeable contracts use an initialize function instead, called once through the proxy.
Storage layout is permanent. V2 must interpret the proxy's existing slots the same way V1 did.
The proxy needs somewhere to store the implementation address that won't collide with the implementation's variables.
Where the implementation address lives
ERC-1967 fixes the slots, derived from a hash so no normal variable will ever land there:
That's keccak256("eip1967.proxy.implementation") - 1 and the same for admin. Block explorers read these slots to show "Read as Proxy", and you can too:
MEV is profit from ordering transactions. A sandwich attack front-runs and back-runs a public swap, capped by its slippage. Use tight minimum outputs from fresh quotes and private RPCs.
The upgrade logic lives in the proxy. To avoid clashes between the proxy's admin functions and the implementation's functions, the proxy checks the caller:
calls from the admin go to the proxy's own upgrade function and are never forwarded;
calls from everyone else are always forwarded to the implementation.
In OpenZeppelin Contracts v5, TransparentUpgradeableProxy deploys its own ProxyAdmin contract in its constructor, and that ProxyAdmin is the only address that can upgrade. You control the ProxyAdmin through its owner, ideally a multisig. The admin can't use the application through the proxy, which is why a separate admin contract is used.
Trade-offs: the proxy is bigger and more expensive to deploy, and the admin check adds a little gas to every call. In exchange, the upgrade mechanism can't be removed or broken by a bad implementation.
UUPS proxy
In UUPS (Universal Upgradeable Proxy Standard, ERC-1822), the proxy is minimal and the upgrade function lives in the implementation. You inherit UUPSUpgradeable and decide who can upgrade by overriding _authorizeUpgrade:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.22;
import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
import {OwnableUpgradeable} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol";
contract BoxV1 is Initializable, OwnableUpgradeable, UUPSUpgradeable {
uint256 public value;
/// @custom:oz-upgrades-unsafe-allow constructor
constructor() {
_disableInitializers(); // nobody can initialize the implementation itself
}
function initialize(address initialOwner) public initializer {
__Ownable_init(initialOwner);
}
function setValue(uint256 newValue) external onlyOwner {
value = newValue;
}
function _authorizeUpgrade(address) internal override onlyOwner {}
}
Upgrades go through the proxy as a normal call: upgradeToAndCall(newImplementation, data), which runs _authorizeUpgrade first. OpenZeppelin's version also checks that the new implementation is itself UUPS-compatible before switching.
Trade-offs: cheaper deployment and calls, and you can eventually remove upgradeability by deploying an implementation without the upgrade function. The flip side is that you can do that by accident. If a new version forgets to inherit UUPSUpgradeable, or _authorizeUpgrade has a bug, the proxy may be stuck forever or upgradeable by anyone.
Pitfalls for both patterns
Initialize atomically. Deploy the proxy and call initialize in the same transaction (both OpenZeppelin proxy constructors accept initialization data). Otherwise someone can front-run your initialize and become the owner.
Disable initializers on the implementation. The implementation is a live contract too. The _disableInitializers() constructor above stops anyone from initializing it directly and taking ownership of it.
Never reorder storage. In V2, keep every existing state variable in the same order with the same type. Only add new variables after the existing ones. Don't change the inheritance order. Violating this makes V2 read V1's data from the wrong slots, silently corrupting balances or ownership. OpenZeppelin v5 upgradeable contracts keep their own state in namespaced storage (ERC-7201), which removes most of the need for the older __gap arrays, but your own variables still follow the rules.
Be careful with immutable variables and constructor logic. They're baked into the implementation's bytecode, not stored in the proxy, so they change with every upgrade and can't be set per proxy.
Validate before every upgrade. Use the OpenZeppelin plugins, which compare storage layouts between versions and refuse unsafe changes:
Foundry users get the same checks from the openzeppelin-foundry-upgrades library.
Which to choose
Transparent
UUPS
Upgrade logic lives in
Proxy (plus ProxyAdmin)
Implementation
Deploy cost
Higher
Lower
Per-call overhead
Admin check on every call
None beyond delegatecall
Risk of bricking by a bad upgrade
Low
Real, if V2 drops or breaks _authorizeUpgrade
Can remove upgradeability later
Only by renouncing admin ownership
Yes, by upgrading to a non-UUPS implementation
For new projects, UUPS is the common default and OpenZeppelin's recommendation, as long as you validate each upgrade. Pick Transparent if you want the upgrade path to be independent of implementation code.
Checklist
Replace constructors with initializer functions, and call _disableInitializers() in the implementation's constructor.
Deploy and initialize the proxy in one transaction.
Append-only storage; never reorder, remove or retype variables.
For UUPS, protect _authorizeUpgrade and keep inheriting UUPSUpgradeable in every version.
Run the OpenZeppelin upgrade validation before each upgrade.
Put upgrade rights behind a multisig and, ideally, a timelock.
Approve is safe; unlimited, forgotten allowances and unread permit signatures are not. Approve exact amounts, use permit with short deadlines, read every typed-data signature, and revoke old approvals.
Storage dominates gas costs: pack variables, cache storage reads, use constant, immutable, calldata and custom errors, emit events for off-chain data, and measure every change with forge snapshot.