Блокчейн
Yul и inline assembly
Когда OpenSea переписала свой маркетплейс с Wyvern на Seaport, они сэкономили пользователям 35% gas на каждой сделке. Секрет не в новом алгоритме, а в том, что критические пути написаны на Yul - языке, который позволяет обойти ограничения компилятора Solidity и говорить с EVM напрямую. Один assembly-блок в правильном месте может сэкономить миллионы долларов в год. Но один баг в том же блоке может стоить всех этих миллионов. Как освоить этот инструмент, не порезавшись?
- **Seaport (OpenSea)** - весь hot path matching ордеров написан на Yul: ручное ABI-кодирование, bit-packed параметры, custom errors. Результат - экономия 35% gas на каждой NFT-сделке по сравнению с предыдущим протоколом
- **Uniswap V4 Hooks** - система hooks использует assembly для эффективного извлечения и проверки флагов из адреса hook-контракта через битовые маски, определяя какие callback-функции вызывать
- **Solady (gas-optimized Solidity library)** - альтернатива OpenZeppelin, где ERC20, ERC721 и утилиты переписаны на Yul. Transfer ERC20 стоит на ~40% меньше gas, сохраняя полную совместимость с интерфейсами
Предварительные знания
Язык Yul и inline assembly
Solidity - высокоуровневый язык, который скрывает детали EVM за удобным синтаксисом. Но иногда компилятор генерирует неоптимальный bytecode, или вам нужна операция, которую Solidity не поддерживает напрямую. Для таких случаев существует **Yul** - промежуточный язык Ethereum, встраиваемый прямо в Solidity через блоки `assembly {}`.
**Yul** - это минималистичный язык, разработанный командой Solidity. Он занимает нишу между Solidity и сырым bytecode: достаточно низкоуровневый, чтобы контролировать каждый opcode, но достаточно высокоуровневый, чтобы иметь переменные, условия и функции вместо ручного жонглирования стеком.
Синтаксис Yul намеренно прост. В нём нет типов - **всё является 256-битным словом** (как в самой EVM). Переменные объявляются через `let`, присваивание через `:=`, а все EVM opcodes доступны как встроенные функции:
Зачем вообще спускаться на уровень Yul? Три главные причины:
- **Gas-оптимизация** - Solidity-компилятор добавляет проверки (overflow, array bounds), которые стоят gas. В Yul вы контролируете, какие проверки нужны, а какие - нет
- **Доступ к низкоуровневым операциям** - некоторые opcodes (RETURNDATASIZE, CREATE2, EXTCODECOPY) недоступны или неудобны в чистом Solidity
- **Тонкий контроль над memory** - Yul позволяет работать с памятью побайтово, обходя ABI-кодирование Solidity
Assembly-код **обходит все защиты Solidity**: проверки типов, overflow protection, memory safety. Ошибка в assembly не вызовет ошибку компиляции - она проявится как баг в production, потенциально с потерей средств. Используйте assembly только когда точно понимаете, что делаете.
Чем переменные в Yul отличаются от переменных в Solidity?
Прямой доступ к EVM opcodes
Главная сила Yul - прямой доступ к opcodes EVM. В чистом Solidity вы пишете `balance[msg.sender]`, а компилятор сам генерирует цепочку из десятков opcodes. В Yul вы вызываете каждый opcode напрямую, получая полный контроль над тем, что именно исполняет EVM.
Рассмотрим практический пример - эффективное вычисление `keccak256`. Solidity генерирует лишний код для ABI-кодирования, но в assembly мы можем хешировать данные напрямую из memory:
Один из самых известных assembly-трюков - использование `returndatasize()` вместо `push 0`. До первого external call `returndatasize()` всегда возвращает 0, но стоит **2 gas** вместо **3 gas** за `PUSH1 0x00`:
Битовые операции в assembly позволяют **упаковывать несколько значений** в одно 256-битное слово. Это критично для storage-оптимизации:
Почему returndatasize() используется как замена числу 0 в assembly-оптимизациях?
Memory layout в Solidity
Когда вы пишете assembly-код, работающий с memory, критически важно понимать, как Solidity организует память. Memory - это линейный массив байтов, и Solidity резервирует первые 128 байт (0x00–0x7f) под служебные нужды. Если assembly-код запишет данные в эти зоны неаккуратно, можно повредить внутреннее состояние Solidity.
**Free memory pointer** по адресу `0x40` - сердце memory management в Solidity. Каждый раз, когда Solidity выделяет память (создаёт массив, строку, вызывает `abi.encode`), он читает текущее значение из `0x40`, использует эту область и сдвигает указатель вперёд:
**Scratch space (0x00–0x3f)** можно использовать свободно внутри assembly-блока - Solidity не хранит там постоянных данных. Но **free memory pointer (0x40)** и **zero slot (0x60)** нельзя перезаписывать без восстановления! Повреждение free memory pointer - одна из самых коварных ошибок: код может работать в тестах, но ломаться в production при определённой комбинации вызовов.
Сравним, как Solidity и assembly кодируют данные в memory. Функция `abi.encode` добавляет ABI-паддинг и заголовки, а в assembly можно упаковать данные плотно:
Динамические массивы в memory имеют особую структуру: первые 32 байта хранят длину, а дальше идут элементы. Зная это, можно обращаться к элементам массива без bounds checking:
Что произойдёт, если assembly-код перезапишет значение по адресу 0x40 (free memory pointer) и не восстановит его?
Gas-оптимизация через assembly
Теперь, когда мы владеем Yul, opcodes и memory layout, рассмотрим конкретные паттерны gas-оптимизации. Каждый из них - это компромисс: вы получаете экономию gas, но теряете читаемость и увеличиваете поверхность для ошибок. Ключевой навык - понимать, когда оптимизация оправдана.
**Паттерн 1: Unchecked math.** Начиная с Solidity 0.8, все арифметические операции включают overflow/underflow проверки. В assembly эти проверки отсутствуют, что экономит ~100-200 gas на операцию:
**Паттерн 2: Custom errors через assembly.** Revert с кастомной ошибкой через assembly дешевле, чем `require(condition, "message string")`:
**Паттерн 3: Bit manipulation для флагов.** Вместо нескольких bool переменных (каждая занимает отдельный storage slot или byte) - один uint256 с битовыми флагами:
**Seaport (OpenSea)** - один из самых оптимизированных контрактов в production. Он использует все паттерны выше: bit-packed order parameters, custom errors, ручное ABI-кодирование и минимальные memory-аллокации. Результат - газ для исполнения ордера снижен на 35% по сравнению с предыдущей версией (Wyvern).
**Когда assembly НЕ стоит использовать?** Компилятор Solidity постоянно улучшается. Многие оптимизации, которые раньше требовали assembly, теперь выполняются автоматически (optimizer с `--optimize --optimize-runs 200`). Правило большого пальца:
- **Стоит:** hot paths, вызываемые тысячи раз (DEX swaps, token transfers), библиотечные контракты (OpenZeppelin), протоколы с высоким TVL
- **Не стоит:** одноразовые admin-функции, контракты с малым объёмом транзакций, код в активной разработке (assembly затрудняет рефакторинг)
- **Помните о security audit:** каждая строка assembly увеличивает стоимость аудита. Если экономия gas не покрывает дополнительные расходы на аудит - оптимизация преждевременна
**Распространённая ловушка:** разработчик оптимизирует функцию assembly, экономя 500 gas за вызов. Но функция вызывается 10 раз в месяц, а аудит assembly-кода стоит дополнительно $5,000. При цене gas 30 gwei и ETH $2,000 экономия составит 0.03 в месяц. Окупаемость - 14,000 лет. Всегда считайте ROI оптимизации.
Assembly-код всегда быстрее и эффективнее, чем Solidity - нужно писать всё на assembly для максимальной экономии gas
Компилятор Solidity с включённым optimizer генерирует достаточно эффективный bytecode для большинства случаев. Assembly оправдан только в hot paths с высокой частотой вызовов, где измеримая экономия gas покрывает увеличенную стоимость аудита и риски ошибок
Solidity optimizer (--optimize-runs) анализирует код и применяет десятки оптимизаций автоматически. Ручной assembly-код может даже быть менее эффективным, если разработчик не учитывает все нюансы (например, compiler может лучше распределить стек). При этом каждая строка assembly - это потенциальная уязвимость, невидимая для стандартных инструментов анализа (Slither, Mythril). Реальные протоколы (Uniswap V4, Seaport) используют assembly точечно - в 5-10% кода, а не повсеместно.
Итоги
- **Yul - промежуточный язык** между Solidity и bytecode: нет типов (всё uint256), есть переменные (let), условия (if/switch), циклы (for) и функции. Встраивается через `assembly {}` блоки
- **Все EVM opcodes доступны из Yul** как встроенные функции: mload/mstore (memory), sload/sstore (storage), calldataload (calldata), keccak256, create2 и другие. Это даёт полный контроль над тем, что исполняет EVM
- **Memory layout Solidity фиксирован**: 0x00-0x3f scratch space (безопасно для assembly), 0x40 free memory pointer (нельзя портить!), 0x60 zero slot, 0x80+ пользовательские аллокации. Нарушение layout ведёт к порче данных
- **Gas-оптимизация через assembly**: unchecked math, custom errors, bit-packed storage, calldata вместо memory. Но каждая строка assembly увеличивает риск бага и стоимость аудита
- Как Seaport сэкономил 35% gas точечным использованием assembly - оптимизация оправдана только в hot paths с измеримым ROI, а не как универсальный подход ко всему коду
Связанные темы
Inline assembly соединяет высокоуровневый Solidity с внутренностями EVM, и связан с каждым уровнем стека разработки:
- EVM: виртуальная машина Ethereum — Yul - это тонкая обёртка над opcodes EVM. Понимание стековой машины, memory model и gas cost opcodes - фундамент для написания корректного assembly-кода
- Solidity: основы — Assembly встраивается в Solidity через блоки assembly {}. Знание того, как Solidity компилирует конструкции (mappings, arrays, inheritance) в bytecode, помогает писать совместимый assembly
- Upgradeable контракты — Proxy-паттерн использует assembly для DELEGATECALL и манипуляции storage slots. Assembly в fallback-функции proxy - критический компонент upgradeability
- Gas: экономика вычислений — Вся мотивация inline assembly - оптимизация gas. Понимание gas pricing (EIP-2929 cold/warm, EIP-1559) определяет, где assembly даст реальную экономию
Вопросы для размышления
- Вы пишете DeFi-протокол с TVL в 100M. Swap-функция вызывается 10,000 раз в день. Стоит ли переписать её на assembly? Какие факторы вы бы учли, принимая это решение (gas-экономия, стоимость аудита, риск уязвимости)?
- Free memory pointer (0x40) - соглашение Solidity, а не часть спецификации EVM. Что произошло бы, если бы два языка, компилирующих в EVM bytecode, использовали разные memory layout?
- Solidity optimizer с каждой версией генерирует всё более эффективный bytecode. Может ли наступить момент, когда inline assembly станет полностью ненужным? Или у ручного assembly всегда будет преимущество?