UUPS Proxy

The Universal Upgradeable Proxy Standard (UUPS) is a minimal and gas-efficient pattern for upgradeable contracts. Defined in the ERC-1822 specification, UUPS delegates upgrade logic to the implementation contract itself — reducing proxy complexity and deployment costs.

The OpenZeppelin Stylus Contracts provide a full implementation of the UUPS pattern via UUPSUpgradeable and Erc1967Proxy.

Overview

UUPS uses the ERC-1967 proxy architecture to separate upgrade logic from proxy behavior. Instead of maintaining upgradeability in the proxy, all upgrade control is implemented within the logic contract.

Key components:

  • Proxy Contract (Erc1967Proxy) — delegates calls via delegate_call.

  • Implementation Contract — contains application logic and upgrade control.

  • Upgrade Functions — reside in the implementation, not the proxy.

In Solidity, upgrade safety often relies on an immutable self-address and context checks. Stylus, however, uses a small adaptation that is covered below.

Context Detection in Stylus

Stylus does not currently support the immutable keyword. Instead of storing __self = address(this), the implementation uses a dedicated boolean flag in a unique storage slot to distinguish direct vs delegated (proxy) execution contexts.

// boolean flag stored in a unique slot
logic_flag: bool;

The implementation’s constructor sets logic_flag = true in the implementation’s own storage. When code runs via a proxy (delegatecall), the proxy’s storage does not contain this flag, so reads as false. This enables only_proxy() to check for delegated execution without relying on an address sentinel.

Initialization

Stylus requires explicit initialization for both the implementation and the proxy:

  • Constructor – called exactly once on deployment of the logic (implementation) contract. This sets the implementation-only logic_flag used for context checks.

  • Version setup (set_version) – called via the proxy (delegatecall) to write the logic’s VERSION_NUMBER into the proxy’s storage. This aligns the proxy’s version with the logic and enables upgrade paths guarded by only_proxy().

Call set_version() is triggered automatically via upgrade_to_and_call(). Devs are only required to manually call set_version() when deploying the proxy.

Trade-offs:

  • Storage Cost: Requires one additional storage slot.

  • Runtime Safety: Maintains the same guarantees as the Solidity version.

  • Gas Impact: One-time cost during initialization; negligible runtime overhead.

Why UUPS?

  • Gas Efficient — Upgrades are handled within the logic contract.

  • Secure — Authorization and validation are managed in one place.

  • Standardized — Conforms to ERC-1822 and ERC-1967.

  • Flexible — Upgrade logic can include custom access control and validation.

  • Safe by Design — Uses dedicated ERC-1967 slots to prevent storage collisions.

How It Works

  1. Deploy Erc1967Proxy with an initial implementation and encoded set_version data.

  2. Proxy delegates all calls to the implementation contract via delegate_call.

  3. Implementation exposes upgrade_to_and_call, guarded by access control (e.g. Ownable).

  4. Upgrades validate the new implementation using proxiable_uuid().

  5. Stylus uses a two-step pattern: constructor on logic deployment, then set_version during proxy setup.

Implementing a UUPS Contract

Minimal example with Ownable, UUPSUpgradeable, and Erc20 logic:

use openzeppelin_stylus::{
    access::ownable::{self, IOwnable, Ownable},
    proxy::{
        erc1967,
        utils::{
            erc1822::IErc1822Proxiable,
            uups_upgradeable::{self, IUUPSUpgradeable, UUPSUpgradeable},
        },
    },
    token::erc20::{self, Erc20, IErc20},
    utils::address,
};

#[derive(SolidityError, Debug)]
enum Error {
    UnauthorizedAccount(ownable::OwnableUnauthorizedAccount),
    InvalidOwner(ownable::OwnableInvalidOwner),
    UnauthorizedCallContext(uups_upgradeable::UUPSUnauthorizedCallContext),
    UnsupportedProxiableUUID(uups_upgradeable::UUPSUnsupportedProxiableUUID),
    InvalidImplementation(erc1967::utils::ERC1967InvalidImplementation),
    InvalidAdmin(erc1967::utils::ERC1967InvalidAdmin),
    InvalidBeacon(erc1967::utils::ERC1967InvalidBeacon),
    NonPayable(erc1967::utils::ERC1967NonPayable),
    EmptyCode(address::AddressEmptyCode),
    FailedCall(address::FailedCall),
    FailedCallWithReason(address::FailedCallWithReason),
    InvalidInitialization(uups_upgradeable::InvalidInitialization),
    InvalidVersion(uups_upgradeable::InvalidVersion),
}

impl From<uups_upgradeable::Error> for Error {
    fn from(e: uups_upgradeable::Error) -> Self {
        match e {
            uups_upgradeable::Error::InvalidImplementation(e) => {
                Error::InvalidImplementation(e)
            }
            uups_upgradeable::Error::InvalidAdmin(e) => Error::InvalidAdmin(e),
            uups_upgradeable::Error::InvalidBeacon(e) => {
                Error::InvalidBeacon(e)
            }
            uups_upgradeable::Error::NonPayable(e) => Error::NonPayable(e),
            uups_upgradeable::Error::EmptyCode(e) => Error::EmptyCode(e),
            uups_upgradeable::Error::FailedCall(e) => Error::FailedCall(e),
            uups_upgradeable::Error::FailedCallWithReason(e) => {
                Error::FailedCallWithReason(e)
            }
            uups_upgradeable::Error::InvalidInitialization(e) => {
                Error::InvalidInitialization(e)
            }
            uups_upgradeable::Error::UnauthorizedCallContext(e) => {
                Error::UnauthorizedCallContext(e)
            }
            uups_upgradeable::Error::UnsupportedProxiableUUID(e) => {
                Error::UnsupportedProxiableUUID(e)
            }
            uups_upgradeable::Error::InvalidVersion(e) => {
                Error::InvalidVersion(e)
            }
        }
    }
}

impl From<ownable::Error> for Error {
    fn from(e: ownable::Error) -> Self {
        match e {
            ownable::Error::UnauthorizedAccount(e) => {
                Error::UnauthorizedAccount(e)
            }
            ownable::Error::InvalidOwner(e) => Error::InvalidOwner(e),
        }
    }
}

#[entrypoint]
#[storage]
struct MyUUPSContract {
    erc20: Erc20,
    ownable: Ownable,
    uups: UUPSUpgradeable,
}

#[public]
#[implements(IErc20<Error = erc20::Error>, IUUPSUpgradeable, IErc1822Proxiable, IOwnable<Error = Error>)]
impl MyUUPSContract {
    // Accepting owner here only to enable invoking functions directly on the
    // UUPS
    #[constructor]
    fn constructor(&mut self, owner: Address) -> Result<(), Error> {
        self.uups.constructor();
        self.ownable.constructor(owner)?;
        Ok(())
    }

    fn mint(&mut self, to: Address, value: U256) -> Result<(), erc20::Error> {
        self.erc20._mint(to, value)
    }

    /// Initializes the contract.
    fn initialize(&mut self, owner: Address) -> Result<(), Error> {
        self.uups.set_version()?;
        self.ownable.constructor(owner)?;
        Ok(())
    }

    fn set_version(&mut self) -> Result<(), Error> {
        Ok(self.uups.set_version()?)
    }

    fn get_version(&self) -> U32 {
        self.uups.get_version()
    }
}

#[public]
impl IUUPSUpgradeable for MyUUPSContract {
    #[selector(name = "UPGRADE_INTERFACE_VERSION")]
    fn upgrade_interface_version(&self) -> String {
        self.uups.upgrade_interface_version()
    }

    #[payable]
    fn upgrade_to_and_call(
        &mut self,
        new_implementation: Address,
        data: Bytes,
    ) -> Result<(), Vec<u8>> {
        // Make sure to provide upgrade authorization in your implementation
        // contract.
        self.ownable.only_owner()?;
        self.uups.upgrade_to_and_call(new_implementation, data)?;
        Ok(())
    }
}

#[public]
impl IErc1822Proxiable for MyUUPSContract {
    #[selector(name = "proxiableUUID")]
    fn proxiable_uuid(&self) -> Result<B256, Vec<u8>> {
        self.uups.proxiable_uuid()
    }
}

Implementing the Proxy

A simple UUPS-compatible proxy using ERC-1967:

use openzeppelin_stylus::proxy::{
    erc1967::{self, Erc1967Proxy},
    IProxy,
};

#[entrypoint]
#[storage]
struct MyUUPSProxy {
    proxy: Erc1967Proxy,
}

#[public]
impl MyUUPSProxy {
    #[constructor]
    fn constructor(&mut self, implementation: Address, data: Bytes) -> Result<(), erc1967::utils::Error> {
        self.proxy.constructor(implementation, &data)
    }

    fn implementation(&self) -> Result<Address, Vec<u8>> {
        self.proxy.implementation()
    }

    #[fallback]
    fn fallback(&mut self, calldata: &[u8]) -> ArbResult {
        unsafe { self.proxy.do_fallback(calldata) }
    }
}

unsafe impl IProxy for MyUUPSProxy {
    fn implementation(&self) -> Result<Address, Vec<u8>> {
        self.proxy.implementation()
    }
}

Upgrade Safety

1. Access Control

Upgrades must be restricted to trusted accounts, e.g. via only_owner:

self.ownable.only_owner()?;

2. Proxy Context Enforcement

Ensures upgrade calls come from a delegate call:

self.uups.only_proxy()?; // Reverts if not called via proxy

Explanation: only_proxy() checks that execution is delegated (not direct), the caller is an ERC-1967 proxy (implementation slot is non-zero), and the proxy-stored version equals the logic’s VERSION_NUMBER.

3. Proxiable UUID Validation

Guarantees compatibility with UUPS:

self.uups.proxiable_uuid()? == IMPLEMENTATION_SLOT;

Initialization

The UUPS proxy supports initialization data that is delegated to the implementation on deployment. This is typically used to invoke set_version first, and optionally invoke your own initialization routines (e.g., ownership or token supply setup) if needed.

let data = IMyContract::setVersionCall {}.abi_encode();
MyUUPSProxy::deploy(implementation_addr, data.into());

⚠️ Initialization Must Be Explicit (Your Contract State)

If your contract needs additional initialization beyond set_version() (e.g., ownership, token supply), expose a properly designed initialization function and protect it appropriately (e.g., single-use guard or access control). Failing to do so can lead to: * Orphaned contracts with no owner. * Uninitialized token supply or core state. * Denial of future upgrades if your own guards are misused.

/// Optional contract initialization (example).
fn init_contract_state(&mut self, owner: Address) -> Result<(), Vec<u8>> {
    self.ownable.constructor(owner)?;

    /// other initialization logic.

    self.uups.set_version()?;

    Ok(())
}
If you expose additional initialization functions, ensure they are protected from re-execution after the proxy is live.

Initializing the Proxy

Initialization data is typically a call to the implementation’s set_version function:

let data = IMyContract::setVersionCall {}.abi_encode();
MyUUPSProxy::deploy(implementation_addr, data.into());

This setup call is run via delegate_call during proxy deployment.

Security Best Practices

  • Restrict upgrade access (e.g. only_owner).

  • Validate all upgrade targets.

  • Test upgrades across versions.

  • Monitor upgrade events (Upgraded).

  • Use empty data unless initialization is needed.

  • Ensure new implementations return the correct proxiable UUID.

  • Enforce proxy context checksonly_proxy() ensures upgrades cannot be called directly on the implementation.

Common Pitfalls

  • Forgetting access control.

  • Direct calls to upgrade logic (not via proxy).

  • Missing proxiable UUID validation.

  • Changing storage layout without planning.

  • Sending ETH to constructor without data (will revert).

  • The VERSION_NUMBER is not increased to the higher value.

Use Cases

  • Upgradeable tokens standards (e.g. ERC-20, ERC-721, ERC-1155).

  • Modular DeFi protocols.

  • DAO frameworks.

  • NFT marketplaces.

  • Access control registries.

  • Cross-chain bridges.