Upgradeable Smart Contracts Explained: 5 Patterns
Quick answer. Upgradeable smart contracts change their logic while keeping their state and address. The five main patterns are eternal storage, transparent proxies, UUPS proxies, beacon proxies and the diamond standard, and each trades simplicity, gas and flexibility differently.
Upgradeable smart contracts keep getting more common, and so do the vulnerabilities that come with upgrades.
This guide walks through five upgradeability patterns. For each one you will see how it works, what it does well, and the mistakes that turn it into a vulnerability.
The five are eternal storage, transparent proxies, Universal Upgradeable Proxy Standard (UUPS) proxies, beacon proxies and the diamond standard. Once you know them, read the five upgradeable contract vulnerabilities that come up most in reviews.
1. Eternal Storage
Eternal storage is the only pattern here that uses no delegatecall and no proxy.
One contract is dedicated to holding all of the application's important data. Logic contracts sit around it and call functions on the data contract to read and change its storage.
That separates the logic from the data. To upgrade part of the protocol, you deploy a new logic contract and give it the same permissions to talk to the data contract.
There can be several logic contracts, all permissioned to read from the data contract and write to it. None of them stores important state of its own.
contract DataStore {
mapping(address => bool) public isController;
mapping(bytes32 => uint256) public uintValues;
mapping(bytes32 => bool) public boolValues;
modifier onlyController() {
require(isController[msg.sender], "not controller");
_;
}
function setUint(bytes32 key, uint256 value) external onlyController {
uintValues[key] = value;
}
function setBool(bytes32 key, bool value) external onlyController {
boolValues[key] = value;
}
}
The address problem, and the router fix
Swapping a logic contract has one obvious flaw. The new contract lives at a different address.
If users have been calling the old logic contract at 0xB, you now have to tell everyone that 0xA is the real one and ask them to migrate.
The fix is to keep users away from the logic contracts. Put a router on top as the system's user interface.
The router knows which logic contract is the most up to date. When you swap one out, the router sends calls to the new one, and users keep using the router at the same address.
Example: GMX V2
The GMX V2 codebase is a good example of this pattern. Its data store contract holds many mappings, such as bytes32 keys to uint256 values, and others for booleans, strings and bytes32 values.
Every function that sets those values is permissioned. Only contracts with controller permission can change the protocol's state in the data store.
Each logic contract holds that controller permission. Users interact with an exchange router up front, which sends them on to the logic they need, such as the logic for increasing a position.
Upsides and pitfalls
- Simplicity. There is no selector clashing or storage collision, the problems that come with delegatecall designs.
- Easy replacement. You swap out one logic contract at a time.
- Pitfall: access control. If one setter on the data contract lacked its modifier and was simply external, that would be a critical vulnerability.
- Pitfall: managing access. If a logic contract turns out to be malicious and gets replaced, make sure its controller status is removed.
How Delegatecall Works
The other four patterns all rely on delegatecall. Picture two contracts: a proxy and an implementation.
When the proxy delegatecalls the implementation, it hands the implementation control of its own execution environment. The implementation's functions run, but they act on the proxy's storage.
Say you call function xyz on the proxy from your externally owned account (EOA). The proxy keeps the whole context of your transaction and asks, in effect, what the implementation would do with it.
Inside the delegatecall, msg.sender is still your EOA, address(this) is still the proxy, and msg.value is whatever you sent.
Anything that writes storage or emits events happens in the proxy. If the implementation says "store something at slot 1", the proxy's slot 1 gets written.
That behavior is the root of most pitfalls in the delegatecall patterns below.
2. Transparent Proxies
A transparent proxy stores an implementation address in its own storage. On every call from a user, it delegatecalls that address.
The implementation address effectively defines what the contract is. Change it, and you change the code that runs when anyone calls the proxy.
The base proxy
OpenZeppelin's TransparentUpgradeableProxy inherits from ERC1967Proxy, which builds on a base Proxy contract.
At its core, the base proxy has a _delegate function. Whenever its fallback is hit, it delegates to the address returned by _implementation().
function _delegate(address implementation) internal virtual {
assembly {
// Copy all of the calldata into memory
calldatacopy(0, 0, calldatasize())
// Forward all gas to the implementation
let result := delegatecall(gas(), implementation, 0, calldatasize(), 0, 0)
// Copy the return data, then revert or return with it
returndatacopy(0, 0, returndatasize())
switch result
case 0 { revert(0, returndatasize()) }
default { return(0, returndatasize()) }
}
}
At a high level, it only forwards whatever calldata the user sent to the implementation, then handles the return data and success or failure.
The ERC-1967 implementation slot
The base proxy does not say where the implementation comes from. ERC1967Proxy answers that by reading it through ERC1967Utils.getImplementation.
ERC-1967 exists to give proxies a consistent storage location for the address of their logic contract.
That slot is the keccak-256 hash of the string eip1967.proxy.implementation, with 1 subtracted to make it even more exotic. The result is 0x3608...2bbc.
getImplementation simply reads that slot and returns its value as an address.
What makes it transparent
So far this is just an ERC-1967 proxy. The transparent proxy builds on it by storing an admin, and only the admin can trigger an upgrade.
It overrides the fallback. If msg.sender is the admin and the first four bytes of calldata are the upgradeToAndCall selector, it runs an internal dispatch function.
That function slices the selector off msg.data and decodes two things: the new implementation address, then arbitrary bytes to call on the new implementation.
It then writes the new address to the implementation slot. That write is the upgrade.
If the data is non-empty, it calls the new implementation with it, for example to initialize it. If the data is empty, a safety check makes sure no msg.value was sent by accident.
Upsides and downsides
The transparent proxy is probably the easiest delegatecall proxy to understand. It is also relatively cheap to deploy a new one that points at whatever implementation you like.
The downside is that the admin cannot use the application. When the admin calls, the proxy either upgrades or reverts, so the admin never reaches the branch that delegates to the implementation.
To deal with that, the admin is its own contract. Deploying a new transparent proxy also deploys a new ProxyAdmin, stored as an immutable admin.
ProxyAdmin is a very simple contract, but it is still one more contract in the system. Some would call that less than ideal.
Storage collisions
Every delegatecall pattern has to worry about storage collisions and function selector clashing.
Start with the slot that holds the implementation. If the implementation ever wrote to that slot, it would change the contract the proxy delegates to.
Upgrades are the more common case. Solidity assigns slots in declaration order, so an implementation with full 32-byte variables a, b and c puts them in slots 0, 1 and 2 of the proxy.
Now upgrade to an implementation that declares a, b, d, c. a and b line up, but d overwrites the old c, and c moves to a new slot.
Function selector clashing
Take a function add(uint256 a, uint256 b) that returns a uint256. Running cast sig "add(uint256,uint256)" gives its selector, 0x771602f7.
Suppose the implementation has a function with that same selector, and the transparent proxy itself has an add function.
When you call the proxy, it runs its own add. The call never falls back to the implementation.
Within a single contract, the Solidity compiler detects a selector collision. Across two contracts it cannot, and the function on the contract being called directly takes priority.
3. UUPS Proxies
UUPS differs from the transparent proxy in one way. The upgrade and admin logic lives in the implementation, not in the proxy.
OpenZeppelin's UUPSUpgradeable is an abstract contract that your implementation inherits. Its public upgradeToAndCall calls an internal UUPS upgrade function that sets the new implementation.
So even behind a bare-bones proxy with no upgrade code, the implementation carries everything needed to upgrade to a new UUPS implementation.
Upsides
- The proxy stores no admin and needs no upgrade logic.
- The admin can use the system, because no address is reserved for upgrades.
- The fallback no longer checks whether
msg.senderis the admin on every call, which saves gas.
Drawbacks
The number one footgun is _authorizeUpgrade. You must override it yourself in the contract that inherits UUPSUpgradeable, with access control so that only the admin can authorize an upgrade.
Forget it or get it wrong, and anyone can upgrade the contract to an arbitrary implementation. That leaves you with essentially zero security.
contract VaultV1 is Initializable, OwnableUpgradeable, UUPSUpgradeable {
function _authorizeUpgrade(address newImplementation)
internal
override
onlyOwner
{}
}
The second risk: upgrade to an implementation that lacks the upgrade logic, one that is not UUPS compliant, and you can never upgrade again.
Behind a bare ERC-1967 proxy, nothing else can set the implementation slot. A proxiableUUID function checks that a new implementation looks UUPS compliant, which reduces the risk of getting stuck but does not remove it.
Because UUPS uses delegatecall, storage collisions and selector clashing still apply.
4. Beacon Proxies
The beacon proxy is aptly named. Many individual proxies all read from one beacon, and the beacon tells them which implementation address they are all using.
Each proxy then delegatecalls that implementation.
How the two contracts work
The beacon proxy does not store an implementation address at all. It stores the beacon's address as an immutable variable, which lives in the contract's bytecode rather than in a storage slot.
When you call it, the fallback asks _implementation(), which reads the beacon address from the bytecode and calls implementation() on the beacon. The proxy delegates to whatever address comes back.
The UpgradeableBeacon is just as simple. It keeps the implementation in one storage slot, exposes a view function to read it, and has an onlyOwner upgradeTo function to change it.
Upsides
You can coordinate the upgrade of many proxies at exactly the same time by setting one storage slot.
Whether a dozen, a hundred or a thousand beacon proxies point at the beacon, one upgradeTo call changes the implementation all of them use, immediately.
The beacon proxy also drops the admin check on every delegatecall, a small optimization over the transparent proxy.
Downsides
As always with delegatecall, selector clashing is possible if more functions are added to the beacon proxy.
The bigger risk is storage collision. Imagine a thousand beacon proxies listening to one beacon.
Some were used with older implementations and still hold leftover values in slots those versions used. Newer ones have only seen newer implementations and have a different layout.
Each proxy can reflect a slightly different version of the storage layout, version 1.2 or whatever it was. Plan the storage migration with every one of those histories in mind.
5. The Diamond Standard
The diamond standard's reference implementation is far more involved than the others. At its core, though, the idea is simple.
Your proxy contract is called a diamond. It holds a mapping from four-byte function selectors to implementation addresses.
For one selector, the mapping says "delegatecall to this address." Another selector can be mapped to a different contract entirely.
By registering functions this way, you can build essentially unlimited functionality into a single contract.
Think of it as a switch statement on msg.sig, though really it is a mapping lookup. The diamond finds the contract stored for that selector and delegatecalls it.
The diamond is a registry that says where to route each call. To change which contract handles a function, you change one key-value pair.
Upsides
- Granular control over upgrades: you can replace a single function at a time.
- You can manage very large, complex logic with many functions in one diamond.
Downsides
The standard is controversial. The reference implementation is dense, with a lot more than a mapping from selectors to addresses, and some of it is arguably superfluous optimization.
The naming can also confuse anyone coming from the other patterns.
- Diamond: the standard's word for a proxy.
- Facet: an implementation contract. A diamond has many facets.
- Diamond cut: a change to the registry, adding, changing or removing a selector-to-address entry.
- Loupe: the list of delegated functions, the selectors the diamond routes.
The complexity and jargon make the standard harder to onboard to. More complexity means more things can go wrong.
Storage collisions are an especially big concern here. The standard has a whole system dedicated to that problem.
The Five Patterns Compared
| Pattern | How it upgrades | Main upside | Main pitfall |
|---|---|---|---|
| Eternal storage | Swap a logic contract that has data-store permissions | No delegatecall, so no collisions or clashes | Access control on the data contract |
| Transparent proxy | Admin calls upgradeToAndCall on the proxy | Easiest proxy to understand | Admin cannot use the app; extra admin contract |
| UUPS proxy | Upgrade logic in the implementation | Cheaper calls; admin can use the app | Unprotected _authorizeUpgrade; stuck after a bad upgrade |
| Beacon proxy | Owner sets the beacon's implementation | Upgrades many proxies at once | Storage migration across many histories |
| Diamond | Diamond cut changes selector routes | Per-function upgrades; very large systems | Complexity, jargon and storage collisions |
How Guardian Helps
Guardian's smart contract audits start with a design review of governance, roles and upgrade paths, before implementation locks in the expensive risks.
Senior researchers then attack privileged flows and upgrade risk with exploit-driven review. With Vanguard, every later update re-enters the same review loop.
Frequently Asked Questions
What is the difference between a transparent proxy and a UUPS proxy?
In a transparent proxy, the admin and upgrade logic live in the proxy, and the admin cannot use the application. In UUPS, that logic lives in the implementation. Calls are cheaper, but you must override _authorizeUpgrade with access control yourself.
What is a beacon proxy?
A beacon proxy reads its implementation address from a shared beacon contract. One upgradeTo call on the beacon upgrades every proxy that points at it. The trade-off is harder storage migration when those proxies have different histories.
What causes a storage collision in a proxy?
The implementation's logic writes to the proxy's storage in declaration order. If an upgrade inserts or reorders variables, a new variable overwrites an old one. An implementation writing to the slot that holds the implementation address is another collision.
What is a facet in the diamond standard?
A facet is an implementation contract. The diamond, which is the proxy, maps each function selector to a facet and delegatecalls it. A diamond cut adds, changes or removes those mappings.
Sources
- Owen Thurm (YouTube): Smart Contract Upgradeability 101 | 5 Upgradeability Methods (August 2023)
- Ethereum Improvement Proposals: ERC-1967, Proxy Storage Slots
- Ethereum Improvement Proposals: ERC-2535, Diamonds, Multi-Facet Proxy
- OpenZeppelin Contracts 5.x documentation: Proxy (transparent, UUPS and beacon proxies)
Get your upgrade paths reviewed
Guardian reviews proxies, roles and upgrade paths before a bad upgrade can reach your users.