Если вы пишете на Solidity больше полугода, вы наверняка уже сталкивались с прокси‑контрактами. Если нет — сталкивались, просто не знали об этом: почти каждый серьёзный протокол на EVM использует какой‑нибудь прокси‑паттерн. Uniswap, Aave, OpenSea — все они. А в основе всех прокси лежит один опкод — DELEGATECALL.
Что такое delegatecall и чем он отличается от call
Начнём с базы. В EVM есть несколько способов одному контракту вызвать другой: call, staticcall и delegatecall. Разница между call и delegatecall — фундаментальная.
Когда контракт A делает call к контракту B, код B исполняется в контексте B. Хранилище B, msg.sender — это адрес A, msg.value — сколько вы переслали. Всё логично.
Когда контракт A делает delegatecall к контракту B, код B исполняется в контексте A. Хранилище A, msg.sender — остаётся оригинальным вызывающим (тот, кто позвал A), msg.value — тоже оригинальный. Контракт B как будто подселился в тело A и пользуется его хранилищем, его балансом, его всем.
Вот минимальный пример, чтобы ощутить разницу:
// SPDX-License-Identifier: MIT pragma solidity ^0.8.24; contract Logic { uint256 public value; // слот 0 address public sender; // слот 1 function setValue(uint256 _val) external { value = _val; sender = msg.sender; } } contract CallerViaCall { uint256 public value; address public sender; function callSetValue(address _logic, uint256 _val) external { // value и sender меняются в Logic, НЕ здесь (bool ok, ) = _logic.call( abi.encodeWithSignature("setValue(uint256)", _val) ); require(ok); } } contract CallerViaDelegatecall { uint256 public value; // слот 0 — сюда запишется _val address public sender; // слот 1 — сюда запишется msg.sender function delegateSetValue(address _logic, uint256 _val) external { // value и sender меняются ЗДЕСЬ, в CallerViaDelegatecall (bool ok, ) = _logic.delegatecall( abi.encodeWithSignature("setValue(uint256)", _val) ); require(ok); } }
Разверните оба контракта, позовите delegateSetValue — и увидите, что value и sender поменялись в CallerViaDelegatecall, а в Logic — нули.
Зачем вообще прокси?
Смарт‑контракт после деплоя нельзя изменить. Код зафиксирован на блокчейне навечно. Нашли баг — деплойте новый контракт, мигрируйте состояние, просите всех пользователей обновить адреса.
Прокси решает проблему так: пользователи взаимодействуют с контрактом‑прокси (его адрес никогда не меняется), а прокси через delegatecall проксирует все вызовы в контракт‑реализацию (implementation). Нужно обновить логику? Деплоите новый implementation, в прокси меняете один адрес. Состояние (хранилище) — в прокси, оно сохраняется между обновлениями.
Fallback‑функция
Прокси‑контракт не знает (и не должен знать) интерфейс реализации. Он пробрасывает любой вызов через fallback. Вот как это выглядит на ассемблере внутри fallback:
// SPDX-License-Identifier: MIT pragma solidity ^0.8.24; contract MinimalProxy { address public implementation; address public admin; constructor(address _impl) { implementation = _impl; admin = msg.sender; } function upgrade(address _newImpl) external { require(msg.sender == admin, "not admin"); implementation = _newImpl; } // Вся магия — здесь fallback() external payable { address impl = implementation; assembly { // Копируем calldata в память, начиная с позиции 0 calldatacopy(0, 0, calldatasize()) // delegatecall к реализации let result := delegatecall( gas(), // весь оставшийся газ impl, // адрес implementation 0, // начало calldata в памяти calldatasize(), // размер calldata 0, // returndata — пока не знаем размер 0 ) // Копируем returndata returndatacopy(0, 0, returndatasize()) switch result case 0 { // delegatecall провалился — revert с данными revert(0, returndatasize()) } default { // Успех — возвращаем данные return(0, returndatasize()) } } } receive() external payable {} }
Разберём, что происходит по шагам. calldatacopy(0, 0, calldatasize()) — копируем входные данные транзакции (селектор функции + аргументы) в память с позиции 0. delegatecall — отправляем эти данные в implementation. returndatacopy — получаем ответ. switch — если вызов провалился, делаем revert, иначе return.
Код работает, но у него есть проблема. Видите переменные implementation и admin? Они занимают слоты 0 и 1. Если контракт‑реализация объявит свои переменные,то они тоже начнут со слота 0. И будет так называемое storage collision.
Storage collision
Пример, который сломает всё:
contract ProxyBroken { address public implementation; // слот 0 address public admin; // слот 1 fallback() external payable { /* delegatecall к implementation */ } } contract ImplementationV1 { uint256 public totalSupply; // слот 0 — КОЛЛИЗИЯ с implementation! mapping(address => uint256) public balances; // слот 1 — КОЛЛИЗИЯ с admin! function mint(address to, uint256 amount) external { totalSupply += amount; // Перезаписывает адрес implementation! balances[to] += amount; // Пишет в слот, где живёт admin } }
Когда ImplementationV1.mint() выполняется через delegatecall, totalSupply += amount пишет в слот 0 прокси. А в слоте 0 прокси живёт implementation. После вызова mint(addr, 42) адрес implementation превратится в мусор, и прокси сломается навсегда.
EIP-1967: как хранить адрес implementation безопасно
Решение — хранить адрес реализации не в слоте 0, а в псевдослучайном слоте, куда компилятор Solidity гарантированно никогда не положит переменную. Стандарт EIP-1967 определяет конкретные слоты:
// Слот для адреса implementation bytes32(uint256(keccak256("eip1967.proxy.implementation")) - 1) // = 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc // Слот для admin bytes32(uint256(keccak256("eip1967.proxy.admin")) - 1) // = 0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103
Почему -1? Чтобы у слота не было известного прообраза хеша. Если бы мы использовали просто keccak256("eip1967.proxy.implementation"), злоумышленник теоретически мог бы создать mapping, ключ которого при хешировании даст тот же слот. Вычитание единицы ломает эту возможность.
Прокси с EIP-1967:
// SPDX-License-Identifier: MIT pragma solidity ^0.8.24; contract EIP1967Proxy { // Слот для implementation (EIP-1967) bytes32 private constant _IMPLEMENTATION_SLOT = 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc; // Слот для admin (EIP-1967) bytes32 private constant _ADMIN_SLOT = 0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103; event Upgraded(address indexed implementation); constructor(address _impl, address _admin) { _setImplementation(_impl); _setAdmin(_admin); } function _setImplementation(address _impl) private { require(_impl.code.length > 0, "not a contract"); assembly { sstore(_IMPLEMENTATION_SLOT, _impl) } emit Upgraded(_impl); } function _setAdmin(address _admin) private { assembly { sstore(_ADMIN_SLOT, _admin) } } function _getImplementation() private view returns (address impl) { assembly { impl := sload(_IMPLEMENTATION_SLOT) } } function _getAdmin() private view returns (address adm) { assembly { adm := sload(_ADMIN_SLOT) } } function upgradeTo(address _newImpl) external { require(msg.sender == _getAdmin(), "not admin"); _setImplementation(_newImpl); } fallback() external payable { address impl = _getImplementation(); 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()) } } } receive() external payable {} }
Теперь implementation и admin спрятаны в слотах, до которых компилятор не доберётся. Etherscan умеет читать эти слоты и показывать пользователю, какой контракт стоит за прокси.
Паттерны прокси
Базу разобрали. Теперь пару основных вариантов, которые можно использовать.
Transparent Proxy
В transparent proxy логика апгрейда живёт в прокси. Проблема: что, если implementation тоже имеет функцию с тем же 4-байтным селектором? Возникает function selector clash. Селекторы — это первые 4 байта от keccak256 имени функции, и коллизии реальны. Например, collate_propagate_storage(bytes16) имеет тот же селектор, что и burn(uint256).
Transparent proxy решает это так: если вызывающий — admin, вызов идёт к прокси (для апгрейдов). Если кто‑то другой — всегда делегируется в implementation. Admin не может взаимодействовать с реализацией вообще.
fallback() external payable { if (msg.sender == _getAdmin()) { // Admin вызывает функции прокси напрямую // (upgradeTo, changeAdmin и т.д.) // Здесь delegatecall НЕ делаем } else { // Все остальные — delegatecall к implementation _delegate(_getImplementation()); } }
Только тут минус в том что каждый вызов тратит дополнительный газ на SLOAD адреса admin и проверку msg.sender == admin.
UUPS (Universal Upgradeable Proxy Standard)
В UUPS логика апгрейда живёт в implementation, а не в прокси. Прокси становится максимально тонким — только fallback и ничего больше. Это убирает проверку admin из каждого вызова, снижает газ.
// Прокси — минимальный contract UUPSProxy { bytes32 private constant _IMPL_SLOT = 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc; constructor(address _impl, bytes memory _data) { assembly { sstore(_IMPL_SLOT, _impl) } if (_data.length > 0) { (bool ok, ) = _impl.delegatecall(_data); require(ok); } } fallback() external payable { assembly { let impl := sload(_IMPL_SLOT) 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()) } } } receive() external payable {} } // Implementation — содержит логику апгрейда contract ImplementationV1 is UUPSUpgradeable { uint256 public counter; function increment() external { counter++; } // Функция апгрейда — в implementation function upgradeTo(address newImpl) external onlyOwner { require(newImpl.code.length > 0, "not a contract"); assembly { sstore( 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc, newImpl ) } } }
Но если вы вдруг задеплоите implementation без функции upgradeTo или с багом в ней — прокси заблокируется навсегда. Upgrade path потерян. С Transparent Proxy такого не случится, потому что upgradeTo в прокси и никуда не денется.
Initializers вместо конструкторов
Конструктор исполняется при деплое контракта и его код не попадает в runtime. Когда вы деплоите implementation, конструктор отработает в контексте implementation, но прокси об этом ничего не знает. Все переменные, инициализированные в конструкторе, окажутся в хранилище implementation, а не прокси.
Поэтому для прокси используют initialize() — обычную функцию, которую вызывают через delegatecall после деплоя прокси:
pragma solidity ^0.8.24; contract TokenV1 { bool private _initialized; address public owner; string public name; uint256 public totalSupply; modifier initializer() { require(!_initialized, "already initialized"); _initialized = true; _; } // Вместо constructor function initialize( address _owner, string calldata _name, uint256 _supply ) external initializer { owner = _owner; name = _name; totalSupply = _supply; } function mint(address to, uint256 amount) external { require(msg.sender == owner, "not owner"); totalSupply += amount; } }
initialize должна вызываться только один раз. Без модификатора initializer любой может вызвать её повторно и, например, сменить owner. В OpenZeppelin этот модификатор реализован через Initializable — используйте его, не изобретайте велосипед.
Ещё если у implementation есть родительские контракты с конструкторами, их логику нужно явно вызвать внутри initialize. Конструкторы родителей автоматически вызываются в цепочке наследования, а initialize такая вот обычная функция, и автоматики тут нет.
Обновление
При обновлении implementation (V1 →V2) хранилище прокси остаётся прежним. Если V2 переставит переменные местами или удалит какую‑то из середины — слоты поедут, и прокси начнёт читать мусор.
// V1: всё хорошо contract TokenV1 { bool private _initialized; // слот 0 address public owner; // слот 1 uint256 public totalSupply; // слот 2 } // V2: НЕПРАВИЛЬНО — удалили _initialized, слоты сдвинулись contract TokenV2Bad { address public owner; // слот 0 — тут теперь bool! uint256 public totalSupply; // слот 1 — тут address owner! uint256 public cap; // слот 2 — тут totalSupply } // V2: ПРАВИЛЬНО — только добавляем новые переменные в конец contract TokenV2Good { bool private _initialized; // слот 0 — как в V1 address public owner; // слот 1 — как в V1 uint256 public totalSupply; // слот 2 — как в V1 uint256 public cap; // слот 3 — НОВАЯ переменная, добавлена в конец }
Нельзя удалять переменные. Нельзя менять их порядок. Нельзя менять типы. Можно только добавлять новые в конец.
Для проверки используйте slither-check-upgradeability или OpenZeppelin Upgrades Plugin — они сравнивают layout V1 и V2 и ругаются, если что‑то не так.
OpenZeppelin Upgrades на Hardhat
На практике вручную писать прокси не стоит. Используем OpenZeppelin:
// hardhat.config.js — убедитесь, что плагин установлен // npm install @openzeppelin/hardhat-upgrades @openzeppelin/contracts-upgradeable // scripts/deploy.js const { ethers, upgrades } = require("hardhat"); async function main() { const TokenV1 = await ethers.getContractFactory("TokenV1"); // Деплоит прокси + implementation, вызывает initialize const proxy = await upgrades.deployProxy( TokenV1, ["0xYourAddress", "MyToken", 1000000], { initializer: "initialize" } ); await proxy.waitForDeployment(); console.log("Proxy:", await proxy.getAddress()); } // scripts/upgrade.js async function upgrade() { const TokenV2 = await ethers.getContractFactory("TokenV2Good"); // Проверяет совместимость layout, деплоит V2, обновляет прокси const upgraded = await upgrades.upgradeProxy(PROXY_ADDRESS, TokenV2); console.log("Upgraded to V2"); }
OpenZeppelin Upgrades Plugin сам проверяет storage layout, предупреждает о несовместимых изменениях, деплоит EIP-1967 прокси.

В технологии Блокчейн-разработок важна не только теория, но и глубокое практическое понимание. Курс по «Разработке децентрализованных приложений» поможет освоить ключевые механизмы и инструменты для создания безопасных и надёжных масштабируемых решений. Чтобы узнать, подойдет ли вам программа курса, пройдите вступительный тест.
Для знакомства с форматом обучения и экспертами приходите на бесплатные уроки:
10 марта 20:00. «Децентрализованные биржи (DEXы): принцип работы и создание собственной на Solidity». Записаться
24 марта 20:00. «Блокчейн и ИИ: Симбиоз технологий будущего». Записаться
