Розробка смарт-контрактів з Minimal Proxy (EIP-1167 Clone)
При розробці смарт-контрактів ми стикаємося із задачею масового деплою однакових контрактів. Деплой одного контракту стейкінгу коштує 0.05 ETH при ціні газу 30 gwei. Якщо потрібно створити 1000 екземплярів — це 50 ETH на деплой. Ми використовуємо EIP-1167 Minimal Proxy, щоб скоротити ці витрати до 0.003 ETH за екземпляр: сумарно 3 ETH замість 50. Це економія 94% — у 16 разів дешевше звичайного деплою. Наш досвід — 7+ років у блокчейн-інжинірингу, гарантуємо gas-оптимізацію та безпеку. Оцінюємо проєкт за 1-2 дні — зв'яжіться для консультації. Замовте розробку фабрики — отримайте оптимізовані за газом контракти.
Паттерн використовують: 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 означає, що код реалізації виконується в контексті проксі: msg.sender і msg.value зберігаються, address(this) — адреса проксі, storage записується в проксі. Реалізація не має свого 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 до деплою контракту).
| Метод | Opcode | Адреса залежить від | Передбачуваність | Газ |
|---|---|---|---|---|
clone |
CREATE | nonce фабрики | Ні | ~35k |
cloneDeterministic |
CREATE2 | salt + адреса фабрики | Так | ~37k |
CREATE2 додає всього 2-3k газу, але дає передбачуваність адреси, що спрощує інтеграцію з UI і підвищує безпеку.
Чому важливо використовувати CREATE2?
CREATE2 дозволяє передбачити адресу клону до його деплою. Це спрощує взаємодію з UI та смарт-контрактами: можна заздалегідь видати дозволи або провести аудит майбутнього клону. Без CREATE2 адреса стає відома тільки після транзакції, що ускладнює інтеграцію.
Критичний момент: 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 базовий контракт з більш надійним захистом. Використовуємо його, не пишемо свій:
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 вшитий в проксі. Якщо потрібна апгрейдність для множини екземплярів — Beacon Proxy: всі клони дивляться на beacon-контракт, який зберігає адресу реалізації. Змінюємо адресу в beacon — оновлюються всі.
Покрокова інструкція: розгортання фабрики клонів
- Напишіть контракт-реалізацію з initializer замість конструктора.
- Задеплойте реалізацію та викличте
_disableInitializers(). - Створіть фабрику з бібліотекою Clones.
- Використовуйте
cloneDeterministicдля передбачуваної адреси. - Ініціалізуйте клон після деплою.
Деталі реалізації
При використанні Clones.cloneDeterministic важливо обчислювати salt таким чином, щоб гарантувати унікальність адреси. Зазвичай salt включає адресу owner і який-небудь унікальний ідентифікатор (timestamp, nonce). Також необхідно враховувати, що при зміні salt адреса клону змінюється, тому для одного й того ж клону потрібно використовувати фіксований salt.
Типові помилки
Storage collision при зміні реалізації. Якщо деплоїти нову реалізацію з іншим storage layout — старі клони читають storage за старими offset-ами, але нова реалізація інтерпретує їх інакше. Без апгрейдності це не проблема: реалізація фіксована. Але якщо ви вирішуєте додати апгрейдність через Beacon post-factum — потрібен storage gap.
Не передавати ETH в initialize(). Якщо конструктор реалізації приймав ETH — initializer теж повинен. Але в більшості випадків ініціалізація не вимагає ETH — не ускладнюємо.
Використання immutable змінних у реалізації. Immutable зберігається в bytecode реалізації, а не в storage. При DELEGATECALL клон читає storage з себе, але код — з реалізації. Immutable працює коректно: клон читає значення з bytecode реалізації. Це нормальна поведінка, але потрібно розуміти.
Що входить у роботу
- Розробка фабрики смарт-контрактів з підтримкою EIP-1167
- Інтеграція з OpenZeppelin Clones або кастомною реалізацією
- Розгорнута документація коду та процесу деплою
- Аудит безпеки (перевірка на reentrancy, вразливості ініціалізації)
- Навчання команди замовника експлуатації фабрики
- Гарантія виправлення помилок протягом 30 днів після здачі
Строки орієнтовно
Розробка фабрики з Minimal Proxy займає 2-3 робочих дні. Вартість розраховується індивідуально — зв'яжіться для оцінки вашого проєкту.







