Разработка смарт-контрактов на 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 дней |
Оцените сложность вашего проекта — свяжитесь с нами для бесплатной консультации. Получите детальную оценку от инженера.







