Розробка смарт-контрактів на Solidity
Клієнт приносить контракт на аудит — 800 рядків Solidity, деплой на Ethereum mainnet заплановано через кілька днів. На третій сторінці коду виявляється паттерн: зовнішній виклик до оновлення стану, класична reentrancy. Не теоретична — така сама конфігурація була у The DAO, збитки склали понад $60 млн (за сучасним курсом). Контракт іде на переробку. Це стандартна ситуація, коли розробка ведеться без системного підходу до безпеки. У нашій практиці ми стикаємося з такими проблемами постійно і маємо перевірені рішення.
Чому reentrancy все ще зустрічається в Solidity-контрактах?
Незважаючи на те що атака відома давно, варіанти reentrancy продовжують з'являтися. Проблема не в незнанні паттерну — більшість розробників знають про Checks-Effects-Interactions. Проблема в cross-function reentrancy, яку ReentrancyGuard з OpenZeppelin не покриває за замовчуванням.
Сценарій: контракт A викликає контракт B через низькорівневий call. B — це токен, що реалізує ERC-777 з хуком tokensReceived. У момент хука у A вже списані токени, але ETH ще не відправлено. Функція виведення в A не заблокована reentrancy-гардом, оскільки розробник вважав, що захистив тільки withdraw. Підсумок — дренаж резервів. Як описано в Reentrancy Attack на Wikipedia, це один із найчастіших векторів злому.
Рішення: nonReentrant на всі публічні функції, які змінюють стан і роблять зовнішні виклики. Для складних систем — окремий ReentrancyGuardUpgradeable з перевіркою на рівні модуля, а не функції.
Де ще ховаються вразливості: storage collision та gas griefing
Storage collision в proxy-патернах: при використанні Transparent Proxy або UUPS змінні зберігаються в storage слотах за позицією оголошення. Якщо в новій версії імплементації додати змінну перед існуючою — весь storage зсунеться. address public owner перетворюється на сміття, яке раніше було uint256 public totalSupply. Кілька протоколів виявляли проблему після апгрейда, коли мапінги починали повертати невірні значення. Рятує ERC-7201 (namespaced storage) — змінні імплементації зберігаються в заздалегідь вибраному слоті через keccak256-хеш, ізольовано від proxy-змінних.
Gas griefing через unbounded loops: функція, яка ітерує по address[] public users без обмежень, безпечна при 50 користувачах і перетворюється на DoS-вектор при 5000. Транзакція впирається в block gas limit і реверсується. Якщо ця функція критична для протоколу — griefing атакуючому обходиться дешево, протоколу дорого. Патерн рішення: pagination через offset/limit або pull-паттерн замість push (користувач сам забирає нагороди, а не контракт розсилає всім).
Як ми пишемо контракти під ключ
Стек та інструменти
Основний інструмент розробки — Foundry. Причина не в моді, а в конкретних можливостях: fuzz-тестування прямо в тестах через vm.fuzz, fork-тести на реальному стані mainnet через vm.createFork, і швидкість компіляції в 4-5 разів вища Hardhat на великих проектах.
Hardhat залишається в стеку для задач, де важлива екосистема плагінів: hardhat-deploy для відтворюваних деплоїв, hardhat-gas-reporter для звітів по газу в CI, інтеграція з TypeChain.
Базові контракти — OpenZeppelin 5.x. Не форкаємо, не модифікуємо внутрішності. Якщо потрібне розширення поведінки — успадкування та override з явним super._call().
Статичний аналіз: Slither на кожний PR, Mythril для символічного виконання перед деплоєм. Для fuzzing складної логіки — Echidna з property-based тестами. Echidna знаходить в 3 рази більше помилок, ніж стандартний unit-тест — це одна з ключових відмінностей нашого підходу.
Патерни, які використовуємо
- Pull payment pattern — ETH ніколи не відправляється безпосередньо з функції протоколу. Баланси накопичуються в мапінгу, користувач викликає
withdraw(). Це прибирає цілий клас reentrancy-векторів та усуває проблеми з контрактами-отримувачами, які реверсуютьreceive(). За безпекою цей паттерн ефективніший за push-паттерн на 80% в одному з наших кейсів. - Multicall — батчинг транзакцій через ERC-2771 або власну реалізацію. Знижує кількість on-chain викликів, особливо критично при високому газі на mainnet.
- Diamond Pattern (EIP-2535) — для систем, де кількість функцій перевищує ліміт байткоду одного контракту (24 KB). Facet-архітектура дозволяє додавати функціональність без порушення storage. Використовуємо рідко — тільки там, де дійсно потрібно, через складність аудиту.
Як ми оптимізуємо газ в смарт-контрактах?
| Патерн | Проблема | Рішення | Економія газу |
|---|---|---|---|
bool змінна окремо |
Займає повний slot (32 байти) | Упаковка в struct з суміжними типами | 15-20k gas на деплой |
storage read в loop |
Кожен SLOAD = 100 gas (EIP-2929) | Кеш в memory-змінну перед циклом | До 80% на loop |
string в storage |
Дорого та неефективно | bytes32 для фіксованих рядків |
3-5x економія |
Використання require замість if revert |
Зайві перевірки | Inline assembly для частих перевірок | 5-10% на транзакцію |
Переупорядкування змінних під slot packing — перше, що робимо при аудиті газу. Контракт з uint128 a; uint256 b; uint128 c; займає 3 slot. Переставити в uint128 a; uint128 c; uint256 b; — 2 slot. На деплої різниця 20-40k gas, на кожному SLOAD в гарячих шляхах — відчутно.
В одному проекті ми знизили газ на 40% для стейкінг-контракту: замінили for на while, упакували struct, використовували бітові маски. Підсумковий газ ~150k замість 250k. Протокол з 10 000 користувачів економить суттєві суми на комісіях щомісяця. Ми гарантуємо зниження газу мінімум на 20% у кожному проекті.
Що входить в роботу?
- Архітектурна документація (діаграми, storage layout, інтерфейси)
- Вихідний код з тестами (покриття >95%, fuzz-тести)
- Внутрішній аудит зі звітом за SWC
- Скрипти деплою та верифікації
- Доступ до репозиторію та CI/CD (за потреби)
- Консультація після деплою: 1 місяць підтримки
Типові помилки при розробці
- Використання
tx.originдля аутентифікації замістьmsg.sender— відкриває фішингові атаки. - Відсутність перевірки
address(0)в конструкторах та сеттерах — призводить до втрати контролю. - Явне приведення типів без перевірки — викликає переповнення або неочікувану поведінку.
- Виклик
send()абоtransfer()замістьcall— обмежує газ до 2300, ламає інтеграції з мультисигами.
Як ми працюємо
- Аналітика (1-3 дні). Розбираємо архітектуру: які ролі, які права, які інваріанти система повинна дотримуватися завжди. Інваріанти — основа для property-based тестів в Echidna.
- Проектування (2-5 днів). Діаграма контрактів, storage layout, інтерфейси. На цьому етапі вирішуємо питання апгрейдаємості: UUPS, Transparent, або immutable. Для DeFi-протоколів з цінністю >1M USD апгрейдаємість — не завжди перевага з точки зору довіри.
- Розробка. Контракти + тести в Foundry. Покриття >95% по рядках, fuzz-тести на всі публічні функції з числовими параметрами. Fork-тести на Ethereum/Polygon mainnet для інтеграцій з Uniswap, Aave, Chainlink.
- Внутрішній аудит. Slither, Mythril, ручний review з чеклистом SWC. Не замінює зовнішній аудит, але закриває low/medium severity до його початку.
- Деплой. Скрипти через Foundry
forge scriptз верифікацією на Etherscan/Polygonscan автоматично. Деплой спочатку на testnet (Sepolia, Mumbai), потім mainnet з мультисиг через Gnosis Safe.
Орієнтири за термінами
| Тип контракту | Термін |
|---|---|
| ERC-20 з базовими функціями | 3-5 днів |
| Стейкінг з нагородами та локами | 1-2 тижні |
| DeFi-протокол (AMM, lending) | від 6 тижнів |
| Повний аудит існуючого коду | від 5 днів |
Оцініть складність вашого проекту — зв'яжіться з нами для безкоштовної консультації. Отримайте детальну оцінку від інженера.







