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 viadelegate_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’sVERSION_NUMBER
into the proxy’s storage. This aligns the proxy’s version with the logic and enables upgrade paths guarded byonly_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
- Deploy
Erc1967Proxy
with an initial implementation and encodedset_version
data. - Proxy delegates all calls to the implementation contract via
delegate_call
. - Implementation exposes
upgrade_to_and_call
, guarded by access control (e.g.Ownable
). - Upgrades validate the new implementation using
proxiable_uuid()
. - Stylus uses a two-step pattern:
constructor
on logic deployment, thenset_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 checks —
only_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.