китаев.tech

Upgradeable — UUPS Proxy

Как работает UUPS Proxy в Solidity: тонкий ERC-1967 proxy, upgradeTo в implementation и защита _authorizeUpgrade.

UUPS переносит upgrade-логику в implementation

UUPS Proxy — это upgradeable-паттерн, где proxy остаётся тонким: он хранит implementation address и делает delegatecall.

В отличие от Transparent Proxy, функция upgrade находится в implementation. Поэтому каждая новая версия logic-контракта должна сама уметь безопасно обновлять implementation.


Пишем UUPS Proxy

Создайте файл contracts/UUPSProxyDemo.sol:

solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

contract ERC1967Proxy {
    bytes32 private constant IMPLEMENTATION_SLOT =
        0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc;

    error ZeroAddress();
    error InitializationFailed();

    constructor(address initialImplementation, bytes memory initData) payable {
        if (initialImplementation == address(0)) {
            revert ZeroAddress();
        }

        _setImplementation(initialImplementation);

        if (initData.length > 0) {
            (bool success, ) = initialImplementation.delegatecall(initData);

            if (!success) {
                revert InitializationFailed();
            }
        }
    }

    fallback() external payable {
        _delegate(_implementation());
    }

    receive() external payable {
        _delegate(_implementation());
    }

    function _implementation() private view returns (address implementationAddress) {
        bytes32 slot = IMPLEMENTATION_SLOT;

        assembly {
            implementationAddress := sload(slot)
        }
    }

    function _setImplementation(address newImplementation) private {
        bytes32 slot = IMPLEMENTATION_SLOT;

        assembly {
            sstore(slot, newImplementation)
        }
    }

    function _delegate(address implementationAddress) private {
        assembly {
            calldatacopy(0, 0, calldatasize())

            let result := delegatecall(gas(), implementationAddress, 0, calldatasize(), 0, 0)

            returndatacopy(0, 0, returndatasize())

            switch result
            case 0 {
                revert(0, returndatasize())
            }
            default {
                return(0, returndatasize())
            }
        }
    }
}

interface IERC1822Proxiable {
    function proxiableUUID() external view returns (bytes32);
}

abstract contract UUPSUpgradeable {
    bytes32 internal constant IMPLEMENTATION_SLOT =
        0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc;

    address private immutable self = address(this);

    event Upgraded(address indexed implementation);

    error FunctionMustBeCalledThroughDelegatecall();
    error FunctionMustNotBeCalledThroughDelegatecall();
    error ProxyImplementationMismatch(address currentImplementation);
    error NotContract(address account);
    error UnsupportedUUPSImplementation(address implementation);
    error UnsupportedProxiableUUID(bytes32 slot);

    modifier onlyProxy() {
        if (address(this) == self) {
            revert FunctionMustBeCalledThroughDelegatecall();
        }

        if (_getImplementation() != self) {
            revert ProxyImplementationMismatch(_getImplementation());
        }

        _;
    }

    modifier notDelegated() {
        if (address(this) != self) {
            revert FunctionMustNotBeCalledThroughDelegatecall();
        }

        _;
    }

    function proxiableUUID() external view notDelegated returns (bytes32) {
        return IMPLEMENTATION_SLOT;
    }

    function upgradeTo(address newImplementation) external onlyProxy {
        _authorizeUpgrade(newImplementation);
        _upgradeTo(newImplementation);
    }

    function _authorizeUpgrade(address newImplementation) internal virtual;

    function _upgradeTo(address newImplementation) internal {
        if (newImplementation.code.length == 0) {
            revert NotContract(newImplementation);
        }

        try IERC1822Proxiable(newImplementation).proxiableUUID() returns (bytes32 slot) {
            if (slot != IMPLEMENTATION_SLOT) {
                revert UnsupportedProxiableUUID(slot);
            }
        } catch {
            revert UnsupportedUUPSImplementation(newImplementation);
        }

        bytes32 slot = IMPLEMENTATION_SLOT;

        assembly {
            sstore(slot, newImplementation)
        }

        emit Upgraded(newImplementation);
    }

    function _getImplementation() internal view returns (address implementationAddress) {
        bytes32 slot = IMPLEMENTATION_SLOT;

        assembly {
            implementationAddress := sload(slot)
        }
    }
}

contract CounterUUPSV1 is UUPSUpgradeable {
    uint256 public value;
    address public owner;
    bool public initialized;

    event Initialized(address indexed owner);
    event ValueChanged(uint256 newValue);

    error AlreadyInitialized();
    error OnlyOwner(address caller);
    error ZeroAddress();

    constructor() {
        initialized = true;
    }

    function initialize(address initialOwner) external {
        if (initialized) {
            revert AlreadyInitialized();
        }

        if (initialOwner == address(0)) {
            revert ZeroAddress();
        }

        owner = initialOwner;
        initialized = true;

        emit Initialized(initialOwner);
    }

    function increment() external {
        value += 1;

        emit ValueChanged(value);
    }

    function setValue(uint256 newValue) external {
        if (msg.sender != owner) {
            revert OnlyOwner(msg.sender);
        }

        value = newValue;

        emit ValueChanged(newValue);
    }

    function _authorizeUpgrade(address) internal view override {
        if (msg.sender != owner) {
            revert OnlyOwner(msg.sender);
        }
    }
}

contract CounterUUPSV2 is CounterUUPSV1 {
    function decrement() external {
        value -= 1;

        emit ValueChanged(value);
    }
}

Этот пример содержит тонкий proxy и две версии logic-контракта. Пользователь вызывает increment() через proxy, а owner вызывает upgradeTo(address) тоже через proxy.


Как это работает

ERC1967Proxy — минимальный proxy. Он хранит implementation в EIP-1967 slot и делегирует все вызовы в текущую implementation.

UUPSUpgradeable — базовая логика upgrade. Она знает, как записать новый implementation address в storage proxy.

upgradeTo(newImplementation) — функция находится в implementation, но вызывается через proxy. Из-за delegatecall она меняет implementation slot proxy.

onlyProxy() — не даёт вызвать upgradeTo напрямую на implementation address. Вызов должен идти через proxy и текущая implementation должна совпадать с self.

proxiableUUID() — возвращает slot, который использует implementation. Это проверка совместимости UUPS-контрактов.

_authorizeUpgrade(...) — место, где конкретный контракт решает, кто имеет право на upgrade. В примере это только owner.


Почему constructor отключает initialize

constructor() в CounterUUPSV1 выставляет initialized = true только в storage implementation-контракта.

Proxy storage от этого не меняется. Поэтому initialize(initialOwner) всё ещё можно выполнить через proxy, но нельзя захватить сам implementation-контракт прямым вызовом.

Это важно: незаблокированная implementation может стать источником ошибок, особенно если в ней есть опасные функции или upgrade-логика.


UUPS vs Transparent Proxy

В Transparent Proxy upgrade-функции живут в proxy. Implementation отвечает только за бизнес-логику.

В UUPS upgrade-функция живёт в implementation. Proxy меньше и дешевле, но новая implementation обязана сохранить UUPS-логику. Если обновиться на контракт без upgradeTo, дальнейшие upgrades могут стать невозможны.

Главный риск UUPS — неправильная _authorizeUpgrade. Если она пустая или проверяет не тот адрес, любой сможет заменить implementation.


Storage layout

Как и в Transparent Proxy, state хранится на proxy address. Поэтому новые версии должны сохранять старый layout:

solidity
contract CounterUUPSV1 {
    uint256 public value;      // slot 0
    address public owner;      // slot 1
    bool public initialized;   // slot 1, упакован рядом с owner
}

CounterUUPSV2 добавляет только новую функцию. Если нужно добавить переменную, добавляйте её после старого layout.


Частые ошибки

Вызывать upgradeTo на implementation addressonlyProxy должен откатить такой вызов. Upgrade делают через proxy address.

Оставить _authorizeUpgrade без проверки → любой адрес сможет заменить implementation и забрать контракт.

Обновиться на implementation без UUPS-логики → proxy продолжит работать, но следующий upgrade может стать невозможным.

Забыть initialize → owner останется нулевым или proxy сможет инициализировать посторонний адрес.


Что дальше

Материалы китаev.tech публикуются в образовательных целях и не являются инвестиционной рекомендацией. Примеры кода и описания протоколов — для обучения; использование в продакшне на ваш собственный риск. Дисклеймер и политика конфиденциальности.