Skip to content
ZYNQORALABS

Smart contract upgrades: proxies, risks and when not to use them

Upgradeable contracts solve one problem and create others. How proxies work, the most common storage mistakes and the governance an upgrade requires.

By ZynqoraLabs · · 3 min read

ShareLinkedInXWhatsApp

Code deployed to an EVM blockchain is immutable. That's a valuable guarantee for the contract's users and a problem for whoever needs to fix a defect or evolve a rule. Proxy patterns exist to reconcile the two, but they trade one risk for another: whoever can upgrade the contract can, in principle, change everything it does.

This article explains how proxies work, where they tend to fail, and when the best decision is not to use one.

How a proxy works

A proxy separates state from logic across two contracts. Users interact with the proxy, which holds the storage and the balance. Every call is forwarded with delegatecall to an implementation contract that holds the code. Because delegatecall runs the implementation's code in the proxy's context, the variables read and written are the proxy's.

Upgrading means pointing the proxy at a new implementation. The address users know doesn't change, and state is preserved.

The implementation's address lives in a storage slot standardized by EIP-1967, chosen so it can't collide with the contract's own variables.

Transparent or UUPS

The two most common patterns differ in where the upgrade function lives:

Transparent proxyUUPS (EIP-1822)
Upgrade logicIn the proxyIn the implementation
Per-call costHigher (the proxy checks who is calling)Lower
Characteristic riskSeparate admin, more moving partsAn implementation without the upgrade function locks the contract forever

With UUPS, every new implementation must keep inheriting the upgrade logic. Forgetting it in one version makes the contract permanently non-upgradeable. Upgrade-validation tools check for this before deployment.

The most expensive mistake: storage layout

The proxy stores variables by position, not by name. If version 2 of the implementation reorders, removes or retypes an existing variable, it starts reading and writing the wrong positions. The result is silent data corruption: balances attributed to the wrong variable, permissions swapped.

The practical rules:

  • Never remove, reorder or retype variables that have already been deployed.
  • Only append new variables, or use namespaced storage (ERC-7201), which isolates each module in its own region.
  • In inherited contracts, reserve space with storage gaps, so a base class can gain variables without shifting its children's.
VaultV1.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
 
import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
import {AccessControlUpgradeable} from "@openzeppelin/contracts-upgradeable/access/AccessControlUpgradeable.sol";
 
contract VaultV1 is Initializable, UUPSUpgradeable, AccessControlUpgradeable {
    bytes32 public constant UPGRADER_ROLE = keccak256("UPGRADER_ROLE");
 
    /// @custom:oz-upgrades-unsafe-allow constructor
    constructor() {
        _disableInitializers(); // the implementation itself can never be initialized
    }
 
    function initialize(address upgrader) external initializer {
        __AccessControl_init();
        _grantRole(UPGRADER_ROLE, upgrader); // a timelock, not a personal wallet
    }
 
    function _authorizeUpgrade(address) internal override onlyRole(UPGRADER_ROLE) {}
}

Initializers instead of constructors

The implementation's constructor runs in the implementation's own context, not the proxy's. So upgradeable contracts use an initialize function, called once through the proxy.

Two precautions prevent well-known incidents:

  1. The initialize function must be protected so it can run only once.
  2. The implementation itself must have initializers disabled in its constructor. Otherwise anyone can initialize it directly and take administrative roles on it.

Governance: who can upgrade

An upgradeable contract is only as trustworthy as the key that authorizes upgrades. A single personal wallet holding that power is a single point of failure, both technical and human.

Common practices:

  • A multisig to propose upgrades, requiring several independent signatures.
  • A timelock between proposal and execution, giving users time to review the change and, if they disagree, exit.
  • Review of the new implementation before execution, as rigorous as for the original contract, including an independent audit when the contract holds value.

When not to use a proxy

Upgradeability isn't mandatory. Immutable contracts are simpler to audit and offer a strong guarantee: nobody can change the rules afterwards. In many cases that is exactly what users need.

Alternatives to upgrading include deploying a new version and offering voluntary migration, or keeping the core immutable and placing frequently changing rules in separate, replaceable modules.

The useful question isn't "should the contract be upgradeable?" but "who should be able to change what, and with what guarantees for users?".

Summary

Proxies let you fix and evolve contracts without changing address, at the cost of new risks: storage corruption, improper initialization and concentrated power in whoever authorizes upgrades. Validating storage layout before every deployment, disabling initializers on the implementation, protecting upgrades with a multisig and a timelock, and seriously considering immutability keep those risks in check. None of these practices replaces an independent audit when value is at stake.