Proxy & Upgradability

Difficulty: Intermediate

Smart contracts on Ethereum are immutable by default. Once deployed, the bytecode cannot be changed. While immutability is a feature for trust and security, it becomes a problem when bugs are discovered or new features need to be added. The proxy pattern solves this by separating the contract's logic from its state storage, allowing the logic to be swapped out while preserving all stored data.

The proxy pattern works through Solidity's `delegatecall` opcode. When a proxy contract receives a call, it forwards the call to an implementation (logic) contract using `delegatecall`. The key property of `delegatecall` is that it executes the implementation's code in the context of the proxy's storage. This means the implementation reads and writes to the proxy's storage slots, not its own. The proxy holds the state, and the implementation provides the behavior.

The Transparent Proxy pattern, popularized by OpenZeppelin, addresses a subtle issue: what happens when the proxy admin calls a function that exists on both the proxy and the implementation? The transparent proxy solves this by having the proxy check the caller. If the caller is the admin, the proxy handles the call itself (for admin functions like `upgradeTo`). If the caller is anyone else, the call is delegated to the implementation. This prevents function selector clashes but adds a small gas overhead due to the admin check on every call.

The UUPS (Universal Upgradeable Proxy Standard, EIP-1822) pattern takes a different approach by placing the upgrade logic inside the implementation contract itself rather than in the proxy. The proxy is simpler and cheaper to deploy, and upgrade authorization is handled by the implementation's `_authorizeUpgrade` function. This pattern is more gas-efficient for users because there is no admin check on every call, but it requires every implementation to include the upgrade mechanism. If a team accidentally deploys an implementation without the upgrade function, the contract becomes permanently non-upgradeable.

Storage collisions are the most dangerous pitfall when working with proxies. Since the proxy and implementation share storage via `delegatecall`, their storage layouts must be carefully coordinated. If the implementation declares a variable in slot 0, it reads from and writes to slot 0 of the proxy. When upgrading to a new implementation, the new version must maintain the same storage layout for all existing variables and only append new ones at the end. Tools like OpenZeppelin's upgrades plugin can detect storage layout incompatibilities before deployment.

Code examples

Simple Proxy with delegatecall

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

// Implementation (Logic) Contract v1
contract CounterV1 {
    uint256 public count;  // slot 0

    function increment() external {
        count += 1;
    }

    function getCount() external view returns (uint256) {
        return count;
    }
}

// Implementation (Logic) Contract v2
contract CounterV2 {
    uint256 public count;  // slot 0 - must match V1
    uint256 public lastUpdated;  // slot 1 - new variable appended

    function increment() external {
        count += 1;
        lastUpdated = block.timestamp;
    }

    function decrement() external {
        require(count > 0, "Count is zero");
        count -= 1;
        lastUpdated = block.timestamp;
    }

    function getCount() external view returns (uint256) {
        return count;
    }
}

// Proxy Contract
contract SimpleProxy {
    // EIP-1967 implementation slot to avoid storage collisions
    // bytes32(uint256(keccak256('eip1967.proxy.implementation')) - 1)
    bytes32 private constant IMPLEMENTATION_SLOT =
        0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc;

    // bytes32(uint256(keccak256('eip1967.proxy.admin')) - 1)
    bytes32 private constant ADMIN_SLOT =
        0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103;

    constructor(address _implementation) {
        _setImplementation(_implementation);
        _setAdmin(msg.sender);
    }

    function upgradeTo(address _newImplementation) external {
        require(msg.sender == _getAdmin(), "Not admin");
        _setImplementation(_newImplementation);
    }

    function _getAdmin() private view returns (address admin) {
        bytes32 slot = ADMIN_SLOT;
        assembly {
            admin := sload(slot)
        }
    }

    function _setAdmin(address _admin) private {
        bytes32 slot = ADMIN_SLOT;
        assembly {
            sstore(slot, _admin)
        }
    }

    function _getImplementation() private view returns (address impl) {
        bytes32 slot = IMPLEMENTATION_SLOT;
        assembly {
            impl := sload(slot)
        }
    }

    function _setImplementation(address _impl) private {
        bytes32 slot = IMPLEMENTATION_SLOT;
        assembly {
            sstore(slot, _impl)
        }
    }

    // Fallback delegates all calls to the implementation
    fallback() external payable {
        address impl = _getImplementation();
        assembly {
            calldatacopy(0, 0, calldatasize())
            let result := delegatecall(gas(), impl, 0, calldatasize(), 0, 0)
            returndatacopy(0, 0, returndatasize())
            switch result
            case 0 { revert(0, returndatasize()) }
            default { return(0, returndatasize()) }
        }
    }

    receive() external payable {}
}

This proxy stores admin and implementation addresses in EIP-1967 specific storage slots to avoid collisions with the implementation's storage. The fallback function forwards all calls via delegatecall.

UUPS Proxy Pattern (OpenZeppelin)

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol";

contract MyContractV1 is Initializable, UUPSUpgradeable, OwnableUpgradeable {
    uint256 public value;

    /// @custom:oz-upgrades-unsafe-allow constructor
    constructor() {
        _disableInitializers();
    }

    function initialize(address initialOwner) public initializer {
        __Ownable_init(initialOwner);
        __UUPSUpgradeable_init();
        value = 0;
    }

    function setValue(uint256 _value) external {
        value = _value;
    }

    function _authorizeUpgrade(address newImplementation)
        internal
        override
        onlyOwner
    {}
}

contract MyContractV2 is Initializable, UUPSUpgradeable, OwnableUpgradeable {
    uint256 public value;
    uint256 public version;  // New state variable appended

    /// @custom:oz-upgrades-unsafe-allow constructor
    constructor() {
        _disableInitializers();
    }

    function initialize(address initialOwner) public initializer {
        __Ownable_init(initialOwner);
        __UUPSUpgradeable_init();
        value = 0;
    }

    function setValue(uint256 _value) external {
        value = _value;
        version = 2;
    }

    function getVersion() external pure returns (uint256) {
        return 2;
    }

    function _authorizeUpgrade(address newImplementation)
        internal
        override
        onlyOwner
    {}
}

UUPS places the upgrade logic in the implementation contract. The _authorizeUpgrade function controls who can upgrade. Notice that constructors are disabled in favor of initializers since delegatecall does not run constructors.

Key points

Concepts covered

Proxy, delegatecall, UUPS, Transparent Proxy