Back

Upgradeable smart contracts: proxy patterns, risks and when to skip

Transparent, UUPS, beacon and diamond proxies compared, plus storage collisions, initializer bugs, timelocks and the cases where an immutable contract is the better call.

Upgradeable smart contracts: proxy patterns, risks and when to skip

Upgradeable smart contracts use a proxy: a small contract that holds the state and the address, and forwards every call with delegatecall to an implementation contract you can swap later. For most new EVM projects in 2026 the default is a UUPS proxy built on OpenZeppelin's audited contracts, with upgrades gated by a multisig and a timelock. The pattern is mature. The failures come from storage layout mistakes, uninitialized implementations and weak control over who can upgrade, so most of this post is about those.

How a proxy actually works

A proxy has almost no logic. It receives a call, looks up the current implementation address, and runs that implementation's code against the proxy's own storage. Users and integrators only ever see the proxy address, so an upgrade changes behavior without changing the address, balances or allowances.

First, implementation contracts can't use constructors to set state, because a constructor runs against the implementation's storage, not the proxy's. You use an initialize function instead, called once through the proxy. Second, the proxy and every version of the implementation share one storage layout. Reorder a variable between versions and you silently read garbage.

Where the proxy keeps the implementation address matters too. ERC-1967 fixes standard slots for the implementation, admin and beacon addresses, each derived from a keccak hash minus one, so they don't collide with ordinary variables that start at slot 0. It also defines the Upgraded event, which is what block explorers and monitoring tools watch. If someone proposes a proxy that doesn't follow ERC-1967, ask why.

The four patterns compared

PatternWhere upgrade logic livesBest forMain risk
Transparent proxyIn the proxy, controlled by a ProxyAdminTeams that want upgrade logic out of the implementation entirelyHigher deploy and call cost; admin can't use the app through the proxy
UUPS (ERC-1822 style)In the implementation (upgradeToAndCall plus _authorizeUpgrade)Most new single-contract deploymentsA version that drops the upgrade function bricks upgradeability forever
Beacon proxyIn a beacon contract many proxies point toMany instances of the same logic: vaults, per-user accounts, per-market poolsOne beacon upgrade changes every instance at once
Diamond (ERC-2535)In the diamond, routing selectors to many facetsSystems that exceed the contract size limit or need modular facetsComplexity, harder audits, shared storage across facets

Transparent proxy

The upgrade and admin functions sit in the proxy. Calls from the admin go to those functions, and calls from everyone else go to the implementation. In OpenZeppelin Contracts 5.x, the proxy deploys its own ProxyAdmin and only accepts upgrades through it, and the ProxyAdmin's owner decides who can upgrade. It is the most conservative choice because the implementation can't break the upgrade path. You pay for it in gas: the proxy checks the caller on every call, and deployment costs more.

UUPS

UUPS moves the upgrade function into the implementation. The proxy is a plain ERC-1967 proxy, which makes it cheaper. OpenZeppelin's docs say their recommendation is now shifting towards UUPS proxies, and that matches what we deploy. You override _authorizeUpgrade with your access control, typically onlyOwner or a role held by a timelock.

The catch: each new implementation must keep that upgrade function. The OpenZeppelin Upgrades FAQ warns that without a public upgradeTo or upgradeToAndCall you may permanently disable the upgradeability of the contract. Sometimes that's what you want, as a deliberate "freeze" step. Done by accident, it's a production incident with no fix.

Beacon proxy

Each proxy asks a beacon contract for the implementation address. Upgrade the beacon and, in OpenZeppelin's words, all proxies that follow that beacon are automatically upgraded in one transaction. Use it for the same logic deployed many times, like one vault per strategy. Skip it when instances need their own upgrade schedule.

Diamond (ERC-2535)

A diamond maps function selectors to facet contracts and delegates each call to the right facet, all sharing the diamond's storage. It gets you past the 24 KB contract size limit and lets you swap individual functions. Beanstalk ran on a diamond, which is a useful reminder that the pattern was not what failed there; governance was (more on that below).

Our opinion: most teams that reach for a diamond would be better served by a few normal contracts with clear interfaces. Diamonds make audits slower. Pick one only for a real size or modularity problem.

Storage layout: where upgrades break

Every version of the implementation has to agree on what lives in which slot. The rules are simple to state and easy to break in a busy sprint:

  1. Never reorder, remove or change the type of an existing state variable.
  2. Only append new variables, or use a reserved gap or namespace.
  3. Be careful with inheritance. Adding a parent contract shifts the slots of everything after it.
  4. Don't initialize state in declarations (uint256 x = 5;) in an upgradeable contract. That runs in the constructor, which the proxy never sees.

The old fix was uint256[50] private __gap; arrays at the end of each base contract. The current fix is namespaced storage under ERC-7201. You group a contract's variables into a struct and place it at a slot derived from a namespace string, annotated with @custom:storage-location. Each module gets its own region, so adding a variable to one module can't shift another. OpenZeppelin Contracts 5.x uses this pattern internally, and new code should too.

Don't rely on reading diffs by eye. OpenZeppelin's upgrades-core package has a validate command that checks upgrade safety and storage layout compatibility from Hardhat or Foundry build info, and the Foundry plugin wraps the same checks. Run it in CI against the deployed version, not against whatever is on main.

What a collision looks like in production

Audius is the case we point clients to. Its governance proxy kept its admin address in slot 0, which overlapped with OpenZeppelin's Initializable flags. According to Audius's own post-mortem, the bug allowed the initialize functions to be called again, and the attacker used it to take over governance and move 18 million AUDIO tokens. The contracts had been audited twice, by OpenZeppelin in 2020 and Kudelski in 2021, and neither audit caught it. An automated layout check would have flagged the overlap.

Initializers: the other classic bug

Because implementations can't use constructors, they start life uninitialized. If anyone can call initialize on the implementation contract directly, they become its owner. With UUPS, an owner of the implementation could trigger an upgrade that delegatecalls into a contract that self-destructs, bricking every proxy that points at it.

This isn't theoretical. OpenZeppelin disclosed exactly this issue in UUPSUpgradeable in September 2021, shipped a fix in Contracts 4.3.2, and initialized more than 150 exposed implementations across chains before going public. A few months later a whitehat found that Wormhole's core bridge implementation had been left uninitialized after an earlier fix. The contract held about $736 million at the time, and Wormhole paid a $10 million bounty for the report.

The older version of the same mistake is Parity. In 2017 a user initialized the shared wallet library, became its owner and called its self-destruct function, which, per Parity's post-mortem, froze 513,774 ETH across 587 wallets.

The checklist we use:

  • Call _disableInitializers() in the implementation's constructor. OpenZeppelin's docs recommend this explicitly.
  • Pass the initializer calldata to the proxy constructor so the proxy is initialized in the same transaction it's deployed. A separate "initialize later" transaction can be front-run.
  • Use reinitializer(n) for version-specific setup in upgrades, and test that it can only run once.
  • With deep inheritance, check that no parent initializer runs twice. Solidity won't stop you.

Who holds the upgrade key

A proxy makes your contract whatever the upgrade key holder decides it is tomorrow. Serious integrators read your admin setup before they read your code.

A sensible default for a production DeFi or RWA protocol:

  1. A multisig owns upgrades. For example a Safe with 3-of-5 or 4-of-7 signers on separate hardware wallets, held by different people.
  2. A timelock sits between the multisig and the proxy. Users get time to see a queued upgrade and exit if they disagree. 48 hours is a common floor for early protocols; longer for anything holding serious TVL. That's our planning default, not a standard.
  3. A separate, narrow emergency role. It can pause, and nothing else. It can't upgrade or move funds.
  4. Monitoring on the ERC-1967 Upgraded and admin-change events, wired to alerts the whole team sees.
  5. A written plan to decentralize or freeze. For example: hand upgrade rights to on-chain governance after launch, or renounce them after a set period.

Governance itself needs the same care. Beanstalk is the example here. Its emergencyCommit path let a two-thirds supermajority execute a proposal after 24 hours, and voting power was read at execution time rather than from a snapshot. An attacker flash-borrowed enough to control 79% of votes and passed a proposal that drained around $182 million. No contract had a bug in the usual sense. The upgrade path was the bug. If you're designing DAO-controlled upgrades, our guide on how to create a DAO covers snapshot-based voting and timelocks in more detail.

A perfect timelock doesn't help if three of five signers keep keys on the same laptop fleet. Signer hygiene is part of the contract's security model.

When not to make a contract upgradeable

We push back on upgradeability more often than clients expect. Make it immutable when:

  • The contract is simple and well-tested. A standard ERC-20, a vesting contract or a merkle airdrop rarely needs upgrades. An immutable token is easier for exchanges and users to trust.
  • Trust minimization is the product. Core AMM math, a lending primitive other protocols build on, a bridge's verification logic. Integrators build on code they know won't change under them.
  • You can migrate instead. Deploy v2 next to v1 and let users move. It costs more UX effort, but it removes an admin key from your threat model.
  • You can't staff the operational side. Upgradeability needs multisig signers, a timelock process, monitoring and a re-audit for every change. If nobody owns that, the upgrade key is a liability.

A middle ground works well for a lot of products: keep the high-value core immutable and put the parts that change (fee parameters, oracle adapters, reward logic) behind upgradeable or swappable modules. Parameters that only need tuning should be setters behind the timelock, not reasons for a full upgrade. Our smart contract development guide covers that kind of design split, and smart contract security ROI covers what the extra security work costs.

An upgrade release checklist

When you do upgrade, treat it like a new deployment:

  1. Write the change spec and the storage diff. List every new variable and its namespace.
  2. Run the storage layout validator against the deployed implementation in CI.
  3. Fork mainnet in Foundry, execute the upgrade through the real multisig and timelock path, and rerun the full test and invariant suite against the forked state.
  4. Get the diff reviewed by an auditor. Small upgrades still need eyes; the Euler exploit came from a single function added in a later governance change. Our smart contract audit checklist covers what to send.
  5. Deploy the new implementation and verify it on the explorer before queueing.
  6. Queue the upgrade in the timelock and publish what it does and why.
  7. Execute, confirm the Upgraded event, and run post-upgrade smoke tests on mainnet.
  8. Watch monitoring closely for the first 24 to 72 hours.

FAQ

Are upgradeable smart contracts safe?

They are as safe as the upgrade process around them. The proxy code from OpenZeppelin is heavily used and audited. The risks are storage layout mistakes, uninitialized implementations and a weak admin setup. Use ERC-7201 storage, _disableInitializers(), automated layout checks, and a multisig plus timelock.

UUPS vs transparent proxy: which should I use?

UUPS for most new projects. It's cheaper to deploy and call, and OpenZeppelin now recommends it. Transparent proxies keep upgrade logic out of the implementation, which removes the risk of shipping a version without the upgrade function. Pick transparent if that risk worries you more than gas.

Can you upgrade a smart contract that wasn't deployed behind a proxy?

No. Deployed bytecode can't change. You can deploy a new contract and migrate state and users to it, or route new traffic through a new proxy, but the original address keeps its original code.

What is a storage collision?

It happens when two pieces of code use the same storage slot for different variables, for example a proxy's admin address and an implementation's flag. The contract then reads one value as another. ERC-1967 slots and ERC-7201 namespaces exist to prevent it.

How long should an upgrade timelock be?

Long enough for users and integrators to notice and exit. Our planning default is at least 48 hours for early-stage protocols and a week or more once real TVL is at stake. Keep a separate pause role for emergencies so a long timelock doesn't stop you reacting to an exploit.

Does an upgrade need a new audit?

Yes, scaled to the change. A parameter change through an existing setter may only need internal review. Any new or modified logic should get an external diff review, plus a mainnet-fork test of the upgrade itself.

If you're deciding between UUPS, beacons or an immutable design for a contract that will hold real value, we can review your architecture and upgrade governance before you deploy. Talk to our smart contract team and bring your current design; a short call is usually enough to spot the risky parts.

Ready when you are

Let's build something that matters in Web3. Whether you need a prototype next week or a full product launch, we've got the skills to make it happen.

Talk to our team
23stud.ioWe help with what you need - advice, development, team extension, or complete project delivery.
Copyright © 2025 23stud.io. All rights reserved