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:

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

#[public]
#[implements(IErc20<Error = erc20::Error>, IUUPSUpgradeable, IErc1822Proxiable, IOwnable)]
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:

#[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.