Tutoriel·9 min de lecture·Par Solingo

Proxy Patterns Explained — Transparent, UUPS, and Beacon

Understand the three major proxy patterns for upgradeable smart contracts and when to use each.

# Proxy Patterns Explained — Transparent, UUPS, and Beacon

Smart contracts are immutable by default. Once deployed, code cannot change. Proxy patterns solve this by separating storage from logic, enabling upgrades. Let's explore the three main patterns.

How Proxies Work

Basic Concept

// Proxy (never changes)

contract Proxy {

address public implementation;

fallback() external payable {

address impl = implementation;

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()) }

}

}

}

// Implementation V1

contract LogicV1 {

uint256 public value;

function setValue(uint256 newValue) external {

value = newValue;

}

}

// Implementation V2 (upgraded)

contract LogicV2 {

uint256 public value;

function setValue(uint256 newValue) external {

value = newValue * 2; // New logic

}

function getValue() external view returns (uint256) {

return value; // New function

}

}

Users interact with the Proxy address. The Proxy delegatecalls to the implementation, which runs in the Proxy's storage context.

Pattern 1: Transparent Proxy

How It Works

The proxy has two types of callers:

  • Admin: Calls upgrade functions
  • Users: Calls are delegated to implementation
  • contract TransparentProxy {
    

    address public implementation;

    address public admin;

    modifier ifAdmin() {

    if (msg.sender == admin) {

    _;

    } else {

    _fallback();

    }

    }

    function upgradeTo(address newImplementation) external ifAdmin {

    implementation = newImplementation;

    }

    function changeAdmin(address newAdmin) external ifAdmin {

    admin = newAdmin;

    }

    fallback() external payable {

    _fallback();

    }

    function _fallback() internal {

    address impl = implementation;

    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()) }

    }

    }

    }

    Key Feature: Function Clashing Prevention

    If the implementation has an upgradeTo() function, users can't call it — only admin can.

    OpenZeppelin Implementation

    import "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol";
    

    import "@openzeppelin/contracts/proxy/transparent/ProxyAdmin.sol";

    // Deploy

    ProxyAdmin proxyAdmin = new ProxyAdmin();

    TransparentUpgradeableProxy proxy = new TransparentUpgradeableProxy(

    address(implementationV1),

    address(proxyAdmin),

    "" // initialization data

    );

    // Upgrade

    proxyAdmin.upgrade(proxy, address(implementationV2));

    Pros & Cons

    Pros:

    • Simple mental model
    • Admin can't accidentally call implementation functions
    • Widely adopted

    Cons:

    • Extra gas cost (admin check on every call)
    • Requires separate ProxyAdmin contract

    Pattern 2: UUPS (Universal Upgradeable Proxy Standard)

    How It Works

    The upgrade logic lives in the implementation, not the proxy.

    // Minimal UUPS Proxy
    

    contract UUPSProxy {

    address public implementation;

    constructor(address _implementation) {

    implementation = _implementation;

    }

    fallback() external payable {

    address impl = implementation;

    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()) }

    }

    }

    }

    // UUPS Implementation

    contract UUPSImplementation {

    address public owner;

    uint256 public value;

    function initialize(address _owner) external {

    require(owner == address(0), "Already initialized");

    owner = _owner;

    }

    function setValue(uint256 newValue) external {

    value = newValue;

    }

    // Upgrade function (in implementation!)

    function upgradeTo(address newImplementation) external {

    require(msg.sender == owner, "Not owner");

    // Update implementation slot

    assembly {

    sstore(0, newImplementation)

    }

    }

    }

    OpenZeppelin UUPS

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

    import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol";

    contract MyContractV1 is UUPSUpgradeable, OwnableUpgradeable {

    uint256 public value;

    function initialize() public initializer {

    __Ownable_init();

    __UUPSUpgradeable_init();

    }

    function setValue(uint256 newValue) external {

    value = newValue;

    }

    // Required by UUPS

    function _authorizeUpgrade(address newImplementation)

    internal

    override

    onlyOwner

    {}

    }

    // Deploy

    ERC1967Proxy proxy = new ERC1967Proxy(

    address(implementationV1),

    abi.encodeWithSelector(MyContractV1.initialize.selector)

    );

    // Upgrade

    MyContractV1 proxyAsV1 = MyContractV1(address(proxy));

    proxyAsV1.upgradeTo(address(implementationV2));

    Pros & Cons

    Pros:

    • Lower gas (no admin check)
    • Simpler proxy (logic in implementation)
    • More flexible (implementation controls upgrade rules)

    Cons:

    • Risk: If you deploy an implementation without upgradeTo(), contract is bricked
    • More complex implementation code

    Pattern 3: Beacon Proxy

    How It Works

    Multiple proxies point to a single Beacon contract, which stores the implementation address.

    // Beacon
    

    contract UpgradeableBeacon {

    address public implementation;

    address public owner;

    event Upgraded(address indexed implementation);

    constructor(address _implementation) {

    owner = msg.sender;

    implementation = _implementation;

    }

    function upgradeTo(address newImplementation) external {

    require(msg.sender == owner, "Not owner");

    implementation = newImplementation;

    emit Upgraded(newImplementation);

    }

    }

    // Beacon Proxy

    contract BeaconProxy {

    address immutable public beacon;

    constructor(address _beacon) {

    beacon = _beacon;

    }

    fallback() external payable {

    address impl = UpgradeableBeacon(beacon).implementation();

    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()) }

    }

    }

    }

    Use Case: Factory Patterns

    contract WalletFactory {
    

    UpgradeableBeacon public beacon;

    constructor(address implementation) {

    beacon = new UpgradeableBeacon(implementation);

    }

    function createWallet() external returns (address) {

    BeaconProxy proxy = new BeaconProxy(address(beacon));

    return address(proxy);

    }

    // Upgrade ALL wallets at once

    function upgradeAll(address newImplementation) external {

    beacon.upgradeTo(newImplementation);

    }

    }

    OpenZeppelin Beacon

    import "@openzeppelin/contracts/proxy/beacon/UpgradeableBeacon.sol";
    

    import "@openzeppelin/contracts/proxy/beacon/BeaconProxy.sol";

    // Deploy beacon

    UpgradeableBeacon beacon = new UpgradeableBeacon(address(implementationV1));

    // Deploy multiple proxies

    BeaconProxy proxy1 = new BeaconProxy(address(beacon), "");

    BeaconProxy proxy2 = new BeaconProxy(address(beacon), "");

    // Upgrade all at once

    beacon.upgradeTo(address(implementationV2));

    Pros & Cons

    Pros:

    • Upgrade multiple contracts with one transaction
    • Lower deployment cost (proxies are simpler)
    • Centralized upgrade logic

    Cons:

    • Extra indirection (beacon lookup adds gas)
    • Single point of failure (beacon compromise = all proxies)

    Comparison Table

    | Feature | Transparent | UUPS | Beacon |

    |---------|------------|------|--------|

    | Upgrade logic | Proxy | Implementation | Beacon |

    | Gas cost (calls) | High | Low | Medium |

    | Deployment cost | Medium | Low | Low (per proxy) |

    | Multi-upgrade | No | No | Yes |

    | Bricking risk | Low | Medium | Low |

    | Complexity | Low | Medium | High |

    Storage Collisions

    The Problem

    // Proxy storage
    

    contract Proxy {

    address public implementation; // Slot 0

    address public admin; // Slot 1

    }

    // Implementation storage

    contract Logic {

    uint256 public value; // Slot 0 (COLLISION!)

    }

    When Logic writes to value, it overwrites implementation in the proxy!

    Solution: ERC-1967 Storage Slots

    Use random slots for proxy variables:

    // ERC-1967 standard
    

    bytes32 constant IMPLEMENTATION_SLOT =

    bytes32(uint256(keccak256("eip1967.proxy.implementation")) - 1);

    function _getImplementation() internal view returns (address impl) {

    bytes32 slot = IMPLEMENTATION_SLOT;

    assembly {

    impl := sload(slot)

    }

    }

    function _setImplementation(address newImpl) internal {

    bytes32 slot = IMPLEMENTATION_SLOT;

    assembly {

    sstore(slot, newImpl)

    }

    }

    This guarantees no collision with normal storage.

    Initialization (Not Constructors!)

    Proxies can't use constructors — use initialize() instead:

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

    contract MyContract is Initializable {

    address public owner;

    uint256 public value;

    // NOT constructor!

    function initialize(address _owner, uint256 _value)

    public

    initializer // Ensures called only once

    {

    owner = _owner;

    value = _value;

    }

    }

    Safe Upgrade Checklist

  • Never change variable order:
  • // V1

    uint256 public a;

    uint256 public b;

    // V2 — OK

    uint256 public a;

    uint256 public b;

    uint256 public c; // Append only

    // V2 — WRONG

    uint256 public b; // Reordered!

    uint256 public a;

    uint256 public c;

  • Don't change variable types:
  • // V1

    uint256 public value;

    // V2 — WRONG

    address public value; // Type changed!

  • Use storage gaps:
  • contract MyContractV1 {

    uint256 public value;

    uint256[49] private __gap; // Reserve 49 slots

    }

    contract MyContractV2 {

    uint256 public value;

    uint256 public newValue; // Uses 1 gap slot

    uint256[48] private __gap; // 48 slots remaining

    }

  • Test upgrades on testnet:
  • forge script script/Upgrade.s.sol --rpc-url sepolia

    Which Pattern to Choose?

    Use Transparent if:

    • You want the simplest, most battle-tested solution
    • Gas cost is not critical
    • You're uncomfortable with UUPS complexity

    Use UUPS if:

    • Gas optimization matters
    • You want implementation-controlled upgrade logic
    • You're comfortable with the bricking risk (mitigate with tests)

    Use Beacon if:

    • You're deploying many instances (factory pattern)
    • You want to upgrade all at once
    • You have a trusted upgrade controller

    Real-World Examples

    • USDC: Transparent proxy (prioritizes safety)
    • Aave V3: Transparent proxy (audited pattern)
    • Uniswap V3: Immutable (no upgrades)
    • Compound: Transparent proxy with timelock
    • 1inch: UUPS (gas-conscious)

    Key Takeaways

  • Proxies separate storage from logic
  • Transparent = upgrade logic in proxy
  • UUPS = upgrade logic in implementation
  • Beacon = shared upgrade logic for multiple proxies
  • Always use ERC-1967 slots to avoid collisions
  • Never reorder or change variable types
  • Test upgrades rigorously (forge, Hardhat Upgrades plugin)
  • Proxies enable upgrades, but introduce complexity. Use them when necessary, but consider immutability as the default.

    Prêt à mettre en pratique ?

    Applique ces concepts avec des exercices interactifs sur Solingo.

    Commencer gratuitement