китаев.tech

Upgradeable — Transparent Proxy

Как работает Transparent Proxy в Solidity: proxy address, implementation, delegatecall, admin и upgrade без смены адреса.

Transparent Proxy разделяет пользователей и admin

Transparent Proxy — это upgradeable-паттерн, где пользователи вызывают один постоянный proxy address, а proxy выполняет код implementation через delegatecall.

“Transparent” значит: admin не может случайно вызвать пользовательскую функцию implementation через proxy. Admin управляет upgrade, а пользователи работают с бизнес-логикой.


Пишем Transparent Proxy

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

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

contract CounterV1 {
    uint256 public value;
    address public owner;
    bool public initialized;

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

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

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

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

contract CounterV2 is CounterV1 {
    function decrement() external {
        value -= 1;

        emit ValueChanged(value);
    }
}

contract TransparentProxy {
    bytes32 private constant IMPLEMENTATION_SLOT =
        0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc;

    bytes32 private constant ADMIN_SLOT =
        0xb53127684a568b3173ae13b9f8a6016e0196e243e63b6e8ee1178d6a717850b5d6103;

    event Upgraded(address indexed implementation);
    event AdminChanged(address indexed previousAdmin, address indexed newAdmin);

    error ZeroAddress();
    error InitializationFailed();
    error TransparentAdminCannotFallback();

    modifier ifAdmin() {
        if (msg.sender == _admin()) {
            _;
        } else {
            _delegate(_implementation());
        }
    }

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

        _setImplementation(initialImplementation);
        _setAdmin(initialAdmin);

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

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

    function admin() external ifAdmin returns (address) {
        return _admin();
    }

    function implementation() external ifAdmin returns (address) {
        return _implementation();
    }

    function changeAdmin(address newAdmin) external ifAdmin {
        if (newAdmin == address(0)) {
            revert ZeroAddress();
        }

        address previousAdmin = _admin();
        _setAdmin(newAdmin);

        emit AdminChanged(previousAdmin, newAdmin);
    }

    function upgradeTo(address newImplementation) external ifAdmin {
        if (newImplementation == address(0)) {
            revert ZeroAddress();
        }

        _setImplementation(newImplementation);

        emit Upgraded(newImplementation);
    }

    fallback() external payable {
        if (msg.sender == _admin()) {
            revert TransparentAdminCannotFallback();
        }

        _delegate(_implementation());
    }

    receive() external payable {
        if (msg.sender == _admin()) {
            revert TransparentAdminCannotFallback();
        }

        _delegate(_implementation());
    }

    function _admin() private view returns (address adminAddress) {
        bytes32 slot = ADMIN_SLOT;

        assembly {
            adminAddress := sload(slot)
        }
    }

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

        assembly {
            implementationAddress := sload(slot)
        }
    }

    function _setAdmin(address newAdmin) private {
        bytes32 slot = ADMIN_SLOT;

        assembly {
            sstore(slot, newAdmin)
        }
    }

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

Этот файл содержит две версии логики и proxy. Пользователь вызывает increment() по адресу proxy, а admin вызывает upgradeTo() по тому же адресу, но не попадает в fallback implementation.


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

CounterV1 — первая версия логики. Её state-переменные будут храниться не в implementation, а в storage proxy.

CounterV2 is CounterV1 — новая версия сохраняет тот же storage layout и добавляет функцию decrement().

TransparentProxy — постоянный адрес для пользователей. Он хранит адрес implementation и admin в EIP-1967 slots.

delegatecall — выполняет код implementation, но читает и пишет storage proxy. Поэтому value, owner и initialized живут на proxy address.

IMPLEMENTATION_SLOT — специальный storage slot для адреса implementation. Он выбран так, чтобы не пересекаться с обычными переменными контракта.

ADMIN_SLOT — специальный storage slot для admin-адреса proxy.

ifAdmin() — если вызывает admin, выполняется admin-функция proxy. Если вызывает не-admin, вызов уходит в implementation через delegatecall.

upgradeTo(newImplementation) — меняет implementation. Proxy address и весь storage остаются теми же.


Почему proxy называется transparent

В обычном proxy admin мог бы случайно вызвать функцию implementation, если selector совпал с admin-функцией proxy или если он забыл, что работает через admin-кошелёк.

Transparent Proxy решает это правилом:

text
если msg.sender == admin:
  доступны только admin-функции proxy
иначе:
  вызов уходит в implementation через fallback

Поэтому admin не может вызвать increment() через proxy. Для пользовательских вызовов нужен не-admin адрес.


Инициализация вместо constructor

Constructor implementation выполняется только при деплое implementation-контракта. Proxy его не вызывает.

Поэтому upgradeable-контракты используют initialize(...). В примере proxy получает initData и вызывает initialize(initialOwner) через delegatecall в constructor.

Если забыть инициализацию, owner останется нулевым или контракт сможет инициализировать любой адрес. Для upgradeable-контрактов это критическая ошибка.


Storage layout

При upgrade нельзя менять порядок старых storage-переменных:

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

CounterV2 может добавлять новые поля только после старого layout. Следующая крупная переменная после initialized попадёт в slot 2. Если вставить новую переменную перед value, новая implementation начнёт читать старый storage как другие данные.

Это главная цена upgradeability: код можно менять, но storage layout должен оставаться совместимым.


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

Вызывать implementation напрямую → код выполнится, но state будет не storage proxy. Пользовательские вызовы идут на proxy address.

Использовать constructor для business state → proxy не выполнит constructor implementation. Нужен initialize.

Менять порядок storage-полей при upgrade → новая версия сломает старые данные.

Делать admin обычным EOA → ключ admin может обновить логику контракта. В production это обычно multisig или timelock.


Что дальше

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