Skip to content
Learn · Advanced

Upgradeable proxies (Transparent vs UUPS)

How delegatecall proxies work, Transparent vs UUPS tradeoffs, OpenZeppelin upgrade safety, and ways upgrades brick production systems.

Advanced
18 min read

The proxy pattern

Users interact with a proxy address that holds storage. The proxy delegatecalls into an implementation contract whose code executes in the proxy's storage context. Upgrading means pointing the proxy at new implementation bytecode while preserving balances and mappings.

  • delegatecall: implementation logic, proxy storage.
  • CALL vs DELEGATECALL: only the latter preserves msg.sender and storage address.
  • Implementation can be shared by many proxies — each proxy still has isolated storage.
BoxV1.solsolidity
import "@openzeppelin/contracts/proxy/utils/Initializable.sol";
import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";

contract BoxV1 is Initializable, UUPSUpgradeable {
    uint256 private _value;

    function initialize() public initializer {
        __UUPSUpgradeable_init();
    }

    function _authorizeUpgrade(address) internal override onlyOwner {}
}

Transparent proxy

TransparentUpgradeableProxy routes admin-only calls to ProxyAdmin; users hit the implementation via delegatecall. Admin cannot accidentally call user functions through the proxy — selector clashing is handled. Downside: every user call pays extra gas for admin check.

  • ProxyAdmin is a separate contract holding upgrade rights.
  • Admin address should be Timelock or multisig — not an EOA.
  • OZ Hardhat Upgrades plugin deploys and validates compatible layouts.

UUPS (implementation-side upgrades)

Upgrade logic lives in the implementation via UUPSUpgradeable. Proxies are cheaper to call — no per-call admin routing. Risk: if a new implementation omits _authorizeUpgrade or UUPSUpgradeable, upgrades are permanently bricked and you may be unable to fix critical bugs.

  • _authorizeUpgrade gates who may upgrade — typically onlyOwner or onlyRole.
  • Never remove UUPSUpgradeable from the inheritance chain across versions.
  • Storage gaps in upgradeable OZ contracts protect child contract layout.

Initializers not constructors

Implementation contracts disable constructors with _disableInitializers in constructor. Proxies call initialize() once. Re-initialization attacks are blocked by initializer modifier and initialized version flags. Each implementation version may need reinitializer(n) for migration steps.

  • Constructor code runs on implementation deploy — does not affect proxy storage.
  • initialize must set owner/roles explicitly — no default msg.sender owner.
  • Verify initialize was called before accepting deposits.

Storage layout discipline

Upgrades may only append new storage variables. Never reorder, rename, or change types of existing variables. OpenZeppelin storage gaps (__gap) in parent contracts let you add fields in children without shifting parent slots. Run oz-upgrades storage layout checks in CI.

  • Renaming a variable does not change slot — but changing type corrupts data.
  • Removing variables leaves 'dead' slots — still occupied historically.
  • Diamond pattern (EIP-2535) splits logic across facets — advanced layout.

Governor and Timelock upgrades

Production DAOs should route upgrade rights through Timelock so the community sees pending implementation changes. Announce audits, run testnet migrations, and dual-run critical views before switching. Emergency pause (Pausable) is not a substitute for timelocked upgrades.

  • Proposal → Timelock → upgradeToAndCall is the accountable path.
  • upgradeToAndCall can atomically run migration function on new impl.
  • Document implementation addresses in your docs and on-chain events.

When not to upgrade

Immutable contracts maximize user trust — DeFi legos integrate easier when behavior cannot change. Upgrade only when product iteration outweighs trust cost. Many tokens and simple vaults should be non-upgradeable with audited releases.