Back to Blog

Smart Contract Upgrade Safety: Proxy Patterns and Their Pitfalls

|Odin Scan Team
Smart Contract Upgrade Safety: Proxy Patterns and Their Pitfalls

Immutable contracts cannot be patched. If there is a bug, the money is gone. That reality pushed the industry toward upgradeable contracts. The ability to fix issues post-deployment sounds like a pure win.

It is not. Upgradeability introduces its own class of vulnerabilities, some of which are harder to detect and more catastrophic than the bugs they are meant to fix. Storage collisions corrupt state silently. Initialization gaps let attackers take ownership. Selfdestruct in implementation contracts bricks the proxy permanently.

This post covers the major proxy patterns, the specific mistakes that cause the worst outcomes, and how to avoid them.


The Proxy Pattern: How It Works

An upgradeable contract splits into two parts:

Proxy contract: Holds all state (storage) and receives all calls. It does not contain business logic. Instead, it forwards every call to the implementation contract using delegatecall.

Implementation contract: Contains all business logic but holds no state. When the proxy calls it via delegatecall, the implementation's code executes against the proxy's storage.

User -> Proxy (storage lives here) --delegatecall--> Implementation (logic lives here)

To upgrade, you deploy a new implementation contract and point the proxy at it. The storage stays the same. The logic changes.


The Three Major Proxy Patterns

Transparent Proxy (ERC-1967)

The original pattern. The proxy has an admin address that can call upgradeTo(). Regular users interact with the implementation logic. The proxy distinguishes between admin calls (upgrade operations) and user calls (business logic) based on msg.sender.

// Simplified transparent proxy
fallback() external payable {
    if (msg.sender == admin) {
        // Handle admin functions (upgradeTo, changeAdmin)
    } else {
        // Delegate to implementation
        _delegate(implementation);
    }
}

Trade-off: Every call checks msg.sender == admin, adding gas overhead. The admin cannot interact with the implementation as a regular user.

UUPS (Universal Upgradeable Proxy Standard)

The upgrade logic lives in the implementation contract, not the proxy. The proxy is simpler (just a delegatecall forwarder). The implementation includes an upgradeTo() function protected by access control.

// Implementation contract includes upgrade logic
contract MyContractV2 is UUPSUpgradeable {
    function _authorizeUpgrade(address newImplementation) internal override onlyOwner {}
}

Trade-off: If you deploy an implementation that does not include upgrade logic (or has a bug in it), the proxy is permanently bricked. You cannot upgrade to fix the upgrade mechanism itself.

Diamond Pattern (EIP-2535)

Multiple implementation contracts (called "facets") can be registered. Different functions route to different facets. This allows selective upgrades of individual functions without replacing the entire implementation.

Trade-off: Significantly more complex. Storage management across facets requires careful coordination. The attack surface is larger because each facet addition is effectively an upgrade.


Pitfall 1: Storage Layout Collisions

The most dangerous proxy bug. The proxy's storage and the implementation's storage share the same slots. If they overlap, writes to one corrupt the other.

// Proxy stores admin at slot 0
contract Proxy {
    address public admin; // slot 0
}

// Implementation also uses slot 0
contract ImplementationV1 {
    address public owner; // slot 0 -- COLLISION with proxy's admin
    uint256 public value; // slot 1
}

When the implementation writes to owner, it overwrites the proxy's admin. An attacker who can set owner now controls the proxy's admin and can upgrade to a malicious implementation.

Defense: ERC-1967 standardized storage slots for proxy variables. The admin slot is keccak256("eip1967.proxy.admin") - 1, a position so far into the storage space that it will never collide with sequential variable layout.

// ERC-1967 standard slots (no collision with sequential storage)
bytes32 constant ADMIN_SLOT = 0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103;
bytes32 constant IMPLEMENTATION_SLOT = 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc;

Use OpenZeppelin's proxy contracts. They use ERC-1967 slots correctly. Do not implement your own proxy storage layout.


Pitfall 2: Storage Layout Corruption on Upgrade

Even with correct proxy slots, upgrading the implementation can corrupt state if the new implementation changes the storage layout.

// V1 storage layout
contract V1 {
    uint256 public totalDeposits; // slot 0
    address public admin;         // slot 1
}

// V2: WRONG - inserting a variable shifts everything
contract V2 {
    uint256 public newVariable;   // slot 0 -- was totalDeposits
    uint256 public totalDeposits; // slot 1 -- was admin
    address public admin;         // slot 2 -- now reading garbage
}

After the upgrade, totalDeposits reads the old admin value (an address cast to uint256), and admin reads from an empty slot.

Defense:

  • Only append new state variables at the end
  • Never reorder existing variables
  • Never remove or change the type of existing variables
  • Use OpenZeppelin's Upgrades plugin to validate storage layout compatibility
# OpenZeppelin Upgrades plugin catches layout conflicts
npx hardhat run scripts/upgrade.js --network mainnet
# Automatically compares V1 and V2 storage layouts before deploying

Reserve gap slots in your base contracts for future expansion:

contract V1 {
    uint256 public totalDeposits;
    address public admin;
    uint256[48] private __gap; // Reserve 48 slots for future variables
}

contract V2 {
    uint256 public totalDeposits;
    address public admin;
    uint256 public newVariable; // Uses one gap slot
    uint256[47] private __gap; // 47 remaining
}

Pitfall 3: Missing Initialization

Constructors do not work with proxies. The constructor runs when the implementation is deployed, but it sets state on the implementation contract, not the proxy. The proxy's state is uninitialized.

// WRONG: constructor sets state on implementation, not proxy
contract Implementation {
    address public owner;
    constructor() {
        owner = msg.sender; // This sets owner on the implementation, not the proxy
    }
}

The proxy's owner is address(0). Anyone can call a function that checks owner == address(0) or an initializer that sets owner.

// RIGHT: use an initializer function
contract Implementation is Initializable {
    address public owner;

    function initialize(address _owner) external initializer {
        owner = _owner;
    }
}

Critical detail: Call initialize() in the same transaction as the proxy deployment. If there is a gap between deployment and initialization, an attacker can front-run the initialization call and set themselves as owner.

// Deploy and initialize atomically
new ERC1967Proxy(
    address(implementation),
    abi.encodeWithSelector(Implementation.initialize.selector, msg.sender)
);

Pitfall 4: Unprotected Implementation Contract

The implementation contract itself is a regular contract that anyone can interact with directly (not through the proxy). If the implementation has an unprotected initialize() function, an attacker can initialize it, set themselves as owner, and call selfdestruct on it.

If the implementation is selfdestructed, the proxy's delegatecall targets an empty address. All calls to the proxy return success with no data. The proxy is effectively bricked.

// Attacker calls implementation directly (not through proxy)
implementation.initialize(attackerAddress);
// Attacker now owns the implementation
implementation.selfDestruct(); // Proxy is now bricked

Defense:

  • Use OpenZeppelin's _disableInitializers() in the implementation's constructor:
constructor() {
    _disableInitializers(); // Prevents direct initialization of the implementation
}
  • Do not include selfdestruct in implementation contracts
  • Note: selfdestruct was deprecated in EIP-6780 (Dencun upgrade) and no longer destroys contract code on most networks. But older chains and L2s may still support it.

Pitfall 5: UUPS Without Upgrade Authorization

UUPS puts the upgrade logic in the implementation. If the implementation's _authorizeUpgrade() is not properly protected, anyone can upgrade the proxy to a malicious implementation.

// WRONG: no access control on upgrade authorization
function _authorizeUpgrade(address newImplementation) internal override {
    // No check -- anyone can upgrade
}

// RIGHT: restrict to owner
function _authorizeUpgrade(address newImplementation) internal override onlyOwner {
    // Only owner can upgrade
}

Worse: if you deploy a UUPS implementation that does not include the upgrade function at all (forgot to inherit UUPSUpgradeable), the proxy cannot be upgraded ever again. The upgrade path is permanently closed.

Defense: Always inherit UUPSUpgradeable in every version of the implementation. Test the upgrade path as part of your deployment process. Deploy V2 on a fork and verify the upgrade succeeds before deploying to mainnet.


The Upgrade Safety Checklist

Before every upgrade:

  • New implementation only appends state variables (no reordering, no removal)
  • OpenZeppelin Upgrades plugin validates storage layout compatibility
  • _disableInitializers() is called in the implementation constructor
  • UUPS implementations include _authorizeUpgrade() with proper access control
  • Upgrade is tested on a forked mainnet before deployment
  • Post-upgrade tests verify all existing state is readable and correct
  • Post-upgrade tests verify new functionality works as expected
  • No selfdestruct in any implementation contract

Odin Scan detects storage layout collisions, missing initializers, unprotected upgrade functions, and other proxy-specific vulnerabilities across every PR. Catch upgrade bugs before they reach mainnet. Start your free trial.