Разработка смарт-контрактов с Diamond Standard (EIP-2535)
Контракт вырос до 23KB — лимит размера EVM (EIP-170: 24KB на deployed bytecode). Добавить ещё одну фичу некуда. Переписывать всё — это migration, downtime, потеря истории транзакций и потенциально миллионы в TVL под риском. Diamond Standard (EIP-2535) решает эту проблему системно: вместо одного монолитного контракта — один Diamond proxy с произвольным количеством facets, каждый из которых несёт часть логики.
Как работает Diamond: маршрутизация через fallback
Diamond-контракт сам по себе содержит минимум логики. Его fallback() перехватывает все вызовы, смотрит в DiamondStorage — mapping от function selector до адреса facet, и делегирует вызов нужному facet через delegatecall.
fallback() external payable {
DiamondStorage storage ds = diamondStorage();
address facet = ds.selectorToFacet[msg.sig];
require(facet != address(0), "Diamond: function not found");
assembly {
calldatacopy(0, 0, calldatasize())
let result := delegatecall(gas(), facet, 0, calldatasize(), 0, 0)
returndatacopy(0, 0, returndatasize())
switch result
case 0 { revert(0, returndatasize()) }
default { return(0, returndatasize()) }
}
}
Весь state хранится в Diamond (потому что delegatecall исполняет код facet в storage контекста Diamond). Facets — это stateless логика. Это означает, что все facets разделяют одно и то же storage space, что порождает главную специфическую проблему Diamond.
Storage collision: самая опасная ловушка EIP-2535
В стандартном proxy паттерне (EIP-1967) storage collision решается тем, что proxy хранит адрес имплементации по заранее известному слоту, вычисленному через keccak256('eip1967.proxy.implementation') - 1. В Diamond несколько facets разделяют весь storage контракта.
Если Facet A объявляет uint256 public totalSupply в slot 0, а Facet B объявляет address public owner в slot 0 — при чтении owner вернётся интерпретация totalSupply как address. Контракт работает «без ошибок» и даёт неправильные результаты.
Diamond Storage Pattern — решение из EIP-2535: каждый facet хранит свои данные в именованной struct, размещённой по псевдослучайному storage slot:
library LibToken {
bytes32 constant STORAGE_POSITION =
keccak256("diamond.storage.token.v1");
struct TokenStorage {
uint256 totalSupply;
mapping(address => uint256) balances;
mapping(address => mapping(address => uint256)) allowances;
}
function tokenStorage() internal pure returns (TokenStorage storage ts) {
bytes32 position = STORAGE_POSITION;
assembly {
ts.slot := position
}
}
}
Каждый facet использует LibToken.tokenStorage() вместо прямых переменных. Коллизия возможна только если два разных STORAGE_POSITION совпадут — при использовании уникальных строк это практически невозможно.
DiamondCut: добавление и замена facets
Управление facets происходит через diamondCut() — единственная функция, которая меняет routing таблицу Diamond. Это точка контроля для апгрейдов.
struct FacetCut {
address facetAddress;
FacetCutAction action; // Add, Replace, Remove
bytes4[] functionSelectors;
}
function diamondCut(
FacetCut[] calldata _diamondCut,
address _init,
bytes calldata _calldata
) external;
_init + _calldata — optional: адрес контракта и calldata, который будет вызван через delegatecall сразу после изменения facets. Используется для миграции storage при замене facet (analogous to OpenZeppelin's upgradeAndCall).
Право вызывать diamondCut должно быть защищено. Стандартный паттерн: OwnershipFacet контролирует доступ, diamondCut доступен только owner. Для DAO-управляемых протоколов — governance через TimelockController + Governor, который вызывает diamondCut после голосования.
Сравнение с альтернативными proxy паттернами
| Паттерн | Лимит размера | Апгрейдируемость | Сложность | Gas overhead |
|---|---|---|---|---|
| Transparent Proxy (EIP-1967) | 24KB на логику | Полная замена | Низкая | ~2000 gas |
| UUPS (EIP-1822) | 24KB на логику | Полная замена | Средняя | ~1500 gas |
| Beacon Proxy | 24KB, один beacon | Групповая замена | Средняя | ~2500 gas |
| Diamond (EIP-2535) | Безлимитно | Частичная замена | Высокая | ~3000 gas |
Diamond — не всегда правильный выбор. Для контракта до 15KB с простой логикой UUPS проще и дешевле. Diamond оправдан когда: контракт уже близок к лимиту размера, нужна гранулярная апгрейдируемость (обновить только один модуль без замены всего контракта), или логика разрабатывается несколькими командами независимо.
Инструментарий и аудит Diamond
Louper.dev — UI для инспекции Diamond контрактов. Показывает все facets, их function selectors, адреса. Обязательный инструмент для аудиторов и разработчиков.
hardhat-diamond-abi — собирает ABI из всех facets в один файл. Нужен для frontend — frontend видит один контракт, не множество facets.
Nick Mudge's diamond-3 — reference implementation от автора EIP-2535. Используем как базу, не как copy-paste — важно понять каждую строку.
Аудит Diamond-контрактов требует специфической экспертизы: аудиторы проверяют storage layout всех facets на коллизии, корректность diamondCut access control, отсутствие selector clashes (два facet с одним selector). Slither имеет частичную поддержку Diamond, но manual review обязателен.
Процесс разработки
Проектирование facet структуры (2-3 дня). Разбиваем логику на логические модули: TokenFacet, GovernanceFacet, RewardsFacet, AdminFacet. Проектируем storage namespaces для каждого. Это самый важный этап — переработка storage layout после деплоя катастрофична.
Разработка (1.5-2 недели). Каждый facet разрабатывается и тестируется изолированно. Интеграционные тесты — против полного Diamond.
Storage collision check. Перед деплоем запускаем кастомный скрипт, который сравнивает все STORAGE_POSITION значений всех facets на уникальность. Пересечение — блокирующая ошибка.
Деплой и верификация. Diamond деплоится первым, затем каждый facet отдельно, потом diamondCut инициализирует routing. Каждый facet верифицируется на Etherscan. Louper.dev используем для финальной проверки конфигурации.
Сроки: 1-2 недели для системы из 3-5 facets, до месяца для крупного протокола с 10+ facets и сложной governance.







