Розробка смарт-контрактів з 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 визначає стандартний байткод розміром 45 байт, який є проксі до реалізації. Весь байткод — це просто DELEGATECALL на адресу реалізації. Ніякої логіки, ніякого сховища — тільки делегування.
Байткод проксі у hex:
3d602d80600a3d3981f3363d3d373d3d3d363d73<implementation_address>5af43d82803e903d91602b57fd5bf3
Де <implementation_address> — 20-байтова адреса реалізації, закодована в байткоді. Саме тому контракт такий дешевий: буквально 45 байт байткода.
DELEGATECALL означає, що код реалізації виконується в контексті проксі: msg.sender та msg.value зберігаються, address(this) — адреса проксі, сховище записується у проксі. Реалізація не має свого сховища, тільки код.
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) {
// Детерміністичне розгортання через 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
Конструктор реалізації виконується один раз при розгортанні реалізації, а не при розгортанні клонів. Клони отримують чисте сховище. Тому реалізація використовує паттерн 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 з більш надійним захистом. Використовуйте його, не пишіть свій:
import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
contract StakingImplementation is Initializable {
function initialize(...) external initializer {
// ...
}
}
Уразливість: неініціалізована реалізація
Реалізація також є контрактом з публічною функцією initialize(). Якщо ви не викликатимете її самої реалізації — будь-хто може викликати initialize() на реалізації з довільними параметрами та стати власником. Це не ломає клони (у них своє сховище), але є проблемою, якщо реалізація має якусь функціональність або зберігає стан.
Рішення: викликайте _disableInitializers() у конструкторі реалізації:
constructor() {
_disableInitializers(); // OpenZeppelin Initializable
}
Це деактивує initialize() на самому контракті реалізації, залишаючи його робочим тільки через DELEGATECALL з клонів.
Порівняння з іншими паттернами проксі
| Паттерн | Газ на розгортання | Можливість оновлення | Складність | Випадок використання |
|---|---|---|---|---|
| EIP-1167 Clone | ~40k gas | Ні | Низька | Багато екземплярів однієї логіки |
| Transparent Proxy | ~400k gas | Так | Середня | Один оновлюваний контракт |
| UUPS | ~300k gas | Так | Середня | Оновлюваний, логіка в реалізації |
| Beacon Proxy | ~200k gas | Так (всі разом) | Висока | Багато оновлюваних екземплярів |
| Diamond (EIP-2535) | ~500k gas | Так (за facets) | Висока | Складна модульна логіка |
Клони не оновлюються за визначенням: байткод закодований у проксі. Якщо вам потрібна можливість оновлення для багатьох екземплярів — використовуйте Beacon Proxy: всі клони вказують на beacon контракт, який зберігає адресу реалізації. Змініть адресу у beacon — оновляться всі.
Типові помилки
Конфлікт сховища при зміні реалізації. Якщо розгорнути нову реалізацію з іншим макетом сховища — старі клони читають сховище за старими зміщеннями, але нова реалізація їх інтерпретує інакше. Без можливості оновлення це не проблема: реалізація фіксована. Але якщо ви вирішите добавити можливість оновлення через Beacon post-factum — вам потрібен storage gap.
Не передавайте ETH у initialize(). Якщо конструктор реалізації приймав ETH — initializer повинен теж. Але у більшості випадків ініціалізація не вимагає ETH — не ускладнюйте.
Використання immutable змінних у реалізації. Immutable зберігається в байткоді реалізації, а не у сховищі. Під час DELEGATECALL клон читає сховище з себе, але код — з реалізації. Immutable працює коректно: клон читає значення з байткода реалізації. Це нормальна поведінка, але потрібно це розуміти.
Розробка фабрики з Minimal Proxy: 2-3 робочі дні. Вартість розраховується індивідуально.







