Разработка смарт-контрактов с Minimal Proxy (EIP-1167 Clone)
Деплой одного контракта стейкинга стоит 0.05 ETH при цене газа 30 gwei. Если вам нужно создать 1000 экземпляров этого контракта для 1000 пользователей — это 50 ETH на деплой. EIP-1167 Minimal Proxy сокращает это до 0.003 ETH за экземпляр: суммарно 3 ETH вместо 50.
Паттерн используют: Uniswap V2 (пара создаётся как клон фабрики), Gnosis Safe (каждый кошелёк — клон), большинство современных NFT-фабрик.
Что такое Minimal Proxy
EIP-1167 определяет стандартный bytecode размером 45 байт, который является прокси к реализации. Весь bytecode — это просто DELEGATECALL к адресу реализации. Никакой логики, никакого storage — только делегирование.
Bytecode proxy в hex:
3d602d80600a3d3981f3363d3d373d3d3d363d73<implementation_address>5af43d82803e903d91602b57fd5bf3
Где <implementation_address> — 20-байтовый адрес реализации, вшитый в bytecode. Именно потому контракт такой дешёвый: там буквально 45 байт bytecode.
DELEGATECALL означает, что код реализации выполняется в контексте proxy: msg.sender и msg.value сохраняются, address(this) — адрес proxy, storage записывается в proxy. Реализация не имеет своего storage, только код.
OpenZeppelin Clones
OpenZeppelin предоставляет библиотеку Clones для работы с EIP-1167:
import "@openzeppelin/contracts/proxy/Clones.sol";
contract StakingFactory {
address public immutable implementation;
event StakingDeployed(address indexed clone, address indexed owner);
constructor(address _implementation) {
implementation = _implementation;
}
function deployStaking(
address rewardToken,
uint256 rewardRate,
address owner
) external returns (address clone) {
// Детерминированный deploy через CREATE2
bytes32 salt = keccak256(abi.encodePacked(owner, rewardToken, block.timestamp));
clone = Clones.cloneDeterministic(implementation, salt);
// Инициализация (вместо конструктора — функция initialize)
IStaking(clone).initialize(rewardToken, rewardRate, owner);
emit StakingDeployed(clone, owner);
}
// Предсказать адрес до деплоя
function predictAddress(
address owner,
address rewardToken,
uint256 timestamp
) external view returns (address) {
bytes32 salt = keccak256(abi.encodePacked(owner, rewardToken, timestamp));
return Clones.predictDeterministicAddress(implementation, salt);
}
}
Отличие clone от cloneDeterministic
Clones.clone() использует CREATE opcode — адрес зависит от nonce фабрики. Clones.cloneDeterministic() использует CREATE2 — адрес определяется salt и адресом фабрики. CREATE2 предпочтительнее: адрес можно вычислить off-chain до деплоя, что важно для UI и для предварительного одобрения (approve до деплоя контракта).
Критический момент: initializer вместо constructor
Конструктор реализации выполняется один раз при деплое реализации, не при деплое клонов. Клоны получают чистый storage. Поэтому реализация использует паттерн initializer:
contract StakingImplementation {
address public rewardToken;
uint256 public rewardRate;
address public owner;
bool private _initialized;
// Защита от повторной инициализации
modifier initializer() {
require(!_initialized, "Already initialized");
_initialized = true;
_;
}
function initialize(
address _rewardToken,
uint256 _rewardRate,
address _owner
) external initializer {
rewardToken = _rewardToken;
rewardRate = _rewardRate;
owner = _owner;
}
// Бизнес-логика...
}
OpenZeppelin предоставляет Initializable базовый контракт с более robust защитой. Используем его, не пишем свой:
import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
contract StakingImplementation is Initializable {
function initialize(...) external initializer {
// ...
}
}
Уязвимость: неинициализированная реализация
Реализация тоже является контрактом с публичной функцией initialize(). Если её не вызвать на самой реализации — кто угодно может вызвать initialize() на реализации с произвольными параметрами и стать owner. Это не ломает клоны (у них свой storage), но является проблемой если реализация имеет какую-то функциональность или хранит state.
Решение: вызвать _disableInitializers() в конструкторе реализации:
constructor() {
_disableInitializers(); // OpenZeppelin Initializable
}
Это деактивирует функцию initialize() на самом контракте реализации, оставляя её рабочей только через DELEGATECALL из клонов.
Сравнение с другими proxy паттернами
| Паттерн | Gas на deploy | Апгрейдность | Сложность | Использование |
|---|---|---|---|---|
| EIP-1167 Clone | ~40k gas | Нет | Низкая | Много экземпляров одной логики |
| Transparent Proxy | ~400k gas | Да | Средняя | Апгрейдный одиночный контракт |
| UUPS | ~300k gas | Да | Средняя | Апгрейдный, логика в реализации |
| Beacon Proxy | ~200k gas | Да (все сразу) | Высокая | Много апгрейдных экземпляров |
| Diamond (EIP-2535) | ~500k gas | Да (по facets) | Высокая | Сложная модульная логика |
Клоны не апгрейдаемы по определению: bytecode вшит в proxy. Если нужна апгрейдность для множества экземпляров — Beacon Proxy: все клоны смотрят на beacon-контракт, который хранит адрес реализации. Меняем адрес в beacon — обновляются все.
Типичные ошибки
Storage collision при изменении реализации. Если деплоить новую реализацию с другим storage layout — старые клоны читают storage по старым offset-ам, но новая реализация интерпретирует их иначе. Без апгрейдности это не проблема: реализация фиксирована. Но если вы решаете добавить апгрейдность через Beacon post-factum — нужен storage gap.
Не передавать ETH в initialize(). Если конструктор реализации принимал ETH — initializer должен тоже. Но в большинстве случаев инициализация не требует ETH — не усложняем.
Использование immutable переменных в реализации. Immutable хранится в bytecode реализации, а не в storage. При DELEGATECALL клон читает storage из себя, но код — из реализации. Immutable работает корректно: клон читает значение из bytecode реализации. Это нормальное поведение, но нужно понимать.
Разработка фабрики с Minimal Proxy: 2-3 рабочих дня. Стоимость рассчитывается индивидуально.







