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:
// 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 решает это правилом:
если 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-переменных:
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.
Что дальше
- Взаимодействие с Proxy контрактом — прочитайте implementation slot реального USDC proxy
- AccessControl — система ролей — отделите upgrade-admin от операционных ролей
- Pausable контракт — добавьте emergency-паузу на случай ошибочного upgrade