$1,000,000 in security audit grants are live now, Apply here →

← Resource Hub

Upgradeable Smart Contracts Explained: 5 Patterns

Updated 11 min read

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.sender is 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

PatternHow it upgradesMain upsideMain pitfall
Eternal storageSwap a logic contract that has data-store permissionsNo delegatecall, so no collisions or clashesAccess control on the data contract
Transparent proxyAdmin calls upgradeToAndCall on the proxyEasiest proxy to understandAdmin cannot use the app; extra admin contract
UUPS proxyUpgrade logic in the implementationCheaper calls; admin can use the appUnprotected _authorizeUpgrade; stuck after a bad upgrade
Beacon proxyOwner sets the beacon's implementationUpgrades many proxies at onceStorage migration across many histories
DiamondDiamond cut changes selector routesPer-function upgrades; very large systemsComplexity, 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

Get your upgrade paths reviewed

Guardian reviews proxies, roles and upgrade paths before a bad upgrade can reach your users.