Proxy-паттерн, Transparent vs UUPS, столкновения слотов хранения, Initializable, OpenZeppelin Upgrades
Неизменяемость кода — основа доверия в блокчейне. Апгрейдируемость — необходимость бизнеса. Proxy-паттерн примиряет эти противоречия
Задеплоенный контракт нельзя изменить. Если обнаружена уязвимость, баг или нужна новая функция — без proxy придётся деплоить новый контракт и мигрировать всё состояние (данные пользователей, балансы, настройки).
Апгрейдируемость через proxy позволяет: исправлять баги без миграции данных, добавлять функции, изменять логику — при этом адрес контракта и его storage остаются неизменными.
Цена: пользователи должны доверять владельцу прокси. Реальная безопасность требует multisig (Gnosis Safe) или DAO governance для управления обновлениями.
Идея: разделить хранилище (proxy) и логику (implementation).
Proxy-контракт имеет простой fallback: он перенаправляет все вызовы в implementation через delegatecall. При delegatecall код implementation выполняется, но в контексте storage proxy. Таким образом:
User → Proxy (addr: 0xABCD, storage) → delegatecall → Implementation (logic)
(выполняется с storage 0xABCD)
Минималистичный proxy (EIP-1967):
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract SimpleProxy {
// EIP-1967 стандартный слот для адреса implementation
// keccak256("eip1967.proxy.implementation") - 1
bytes32 private constant IMPL_SLOT =
0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc;
constructor(address implementation, bytes memory data) {
_setImplementation(implementation);
if (data.length > 0) {
(bool ok,) = implementation.delegatecall(data);
require(ok, "Init failed");
}
}
fallback() external payable {
address impl = _getImplementation();
assembly {
// Копируем calldata в память
calldatacopy(0, 0, calldatasize())
// delegatecall: выполняем код impl в нашем storage
let result := delegatecall(gas(), impl, 0, calldatasize(), 0, 0)
// Копируем return data
returndatacopy(0, 0, returndatasize())
// Возвращаем или revert
switch result
case 0 { revert(0, returndatasize()) }
default { return(0, returndatasize()) }
}
}
function _getImplementation() internal view returns (address impl) {
assembly { impl := sload(IMPL_SLOT) }
}
function _setImplementation(address newImpl) internal {
assembly { sstore(IMPL_SLOT, newImpl) }
}
}Это главная ловушка proxy-паттерна. Если proxy хранит address implementation в слоте 0, а implementation тоже имеет переменную в слоте 0 — они перезапишут друг друга.
Решение EIP-1967: использовать псевдослучайные слоты для метаданных proxy (admin, implementation address). Слот вычисляется как keccak256(string) - 1 — практически невозможно случайно попасть в тот же слот.
Существует две основные архитектуры апгрейдируемых контрактов:
Transparent Proxy (OpenZeppelin): администратор прокси и пользователи не могут вызывать одни и те же функции. Логика апгрейда живёт в прокси. ProxyAdmin-контракт управляет апгрейдами.
// Пользователь → Proxy → delegatecall → Implementation
// Admin → Proxy → прямой вызов upgradeToAndCallНедостаток: лишняя проверка на каждый вызов (кто обращается — admin или пользователь?), дороже на газ.
UUPS (Universal Upgradeable Proxy Standard, EIP-1822): логика апгрейда живёт в implementation. Proxy проще и дешевле. При обновлении нужно убедиться, что новая implementation тоже содержит логику апгрейда — иначе контракт станет неапгрейдируемым навсегда.
import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol";
contract TokenV1 is OwnableUpgradeable, UUPSUpgradeable {
uint256 public value;
// Вместо constructor — initialize (вызывается только один раз при деплое proxy)
function initialize(address initialOwner) public initializer {
__Ownable_init(initialOwner);
__UUPSUpgradeable_init();
}
function setValue(uint256 _value) external onlyOwner {
value = _value;
}
// Только owner может апгрейдить
function _authorizeUpgrade(address) internal override onlyOwner {}
}В апгрейдируемых контрактах нельзя использовать constructor — он выполняется в контексте implementation контракта, а не proxy. Вместо этого используется initialize функция с модификатором initializer (из OpenZeppelin).
initializer гарантирует, что функция вызывается ровно один раз (хранит флаг _initialized в storage). Это аналог конструктора, но выполняется через delegatecall в proxy.
Важно: при наследовании нескольких Upgradeable контрактов (OwnableUpgradeable, PausableUpgradeable) — вызывайте __Parent_init() в своём initialize, иначе родительское состояние не инициализируется.
На практике используют плагин, который автоматически управляет proxy и проверяет совместимость storage layout:
// scripts/deploy.js
const { ethers, upgrades } = require("hardhat");
async function main() {
const TokenV1 = await ethers.getContractFactory("TokenV1");
// Деплоит proxy + implementation, вызывает initialize
const proxy = await upgrades.deployProxy(TokenV1, [owner.address], {
initializer: "initialize",
kind: "uups"
});
console.log("Proxy deployed to:", await proxy.getAddress());
}
// scripts/upgrade.js
async function upgrade() {
const TokenV2 = await ethers.getContractFactory("TokenV2");
// Проверяет совместимость storage layout перед обновлением
await upgrades.upgradeProxy(proxyAddress, TokenV2);
}Плагин автоматически проверяет: не добавлены ли поля storage перед существующими, не изменились ли типы существующих переменных. Это критично — нарушение layout испортит все данные пользователей.
Вопросы ещё не добавлены
Вопросы для этой подтемы ещё не добавлены.