Deep EVM #11: Jump-таблицы в Huff — O(1) диспатч без оверхеда Solidity
Engineering Team
Проблема диспатчера Solidity
Когда вы вызываете контракт на Solidity, первое, что исполняет EVM — диспатчер функций. Solidity генерирует линейную if-else цепочку, сравнивающую первые 4 байта calldata (селектор функции) с каждым известным селектором:
CALLDATALOAD 0x00
SHR 224
DUP1
PUSH4 0x70a08231 // balanceOf(address)
EQ
PUSH2 dest1
JUMPI
DUP1
PUSH4 0xa9059cbb // transfer(address,uint256)
EQ
PUSH2 dest2
JUMPI
...
Для контракта с N функциями это O(N) — в худшем случае проверяются все N селекторов. Каждое сравнение стоит ~22 газа (DUP1 + PUSH4 + EQ + PUSH2 + JUMPI). Контракт с 20 функциями тратит до 440 газа только на диспатч. Компилятор Solidity (с версии 0.8.22) иногда использует бинарное дерево поиска для больших интерфейсов, доводя до O(log N), но это всё ещё субоптимально.
Для MEV-бота, вызываемого миллионы раз, эти 400+ газа за вызов складываются в реальный ETH.
Подход jump-таблиц: O(1)
Jump-таблица отображает селектор функции непосредственно в смещение кода с помощью арифметики, а не сравнений. Идея заимствована из архитектуры процессоров — вычисляемые GOTO используются в системном программировании с 1960-х.
Концепция:
- Извлечь селектор функции из calldata (4 байта).
- Арифметически вычислить адрес перехода из селектора.
- JUMP непосредственно к этому адресу.
Никаких сравнений, никаких ветвлений, константное время независимо от числа функций.
Подход 1: минимальное кодирование селекторов
Если контракт имеет мало функций (1-8), можно назначить селекторы вручную, намайнив vanity-селекторы, где первый байт или два кодируют уникальное малое целое:
#define macro DISPATCHER() = takes(0) returns(0) {
0x00 calldataload // [calldata_word]
0xe0 shr // [selector]
// Извлекаем маршрутизирующий байт — первый байт селектора
0x18 shr // [first_byte]
// Каждая запись в таблице — 2 байта (PUSH2 offset)
0x02 mul // [offset_in_table]
__tablestart(JumpTable) // [table_start, offset_in_table]
add // [entry_address]
0x00 codecopy
0x00 mload
0xf0 shr
jump
}
#define jumptable JumpTable {
swap_exact
add_liq
remove_liq
flash_loan
}
Подход 2: упакованная таблица в коде
Для максимальной плотности можно упаковать jump-таблицу прямо в байткод, используя __tablestart и __tablesize. Huff нативно поддерживает jump-таблицы как первоклассную конструкцию:
#define jumptable__packed SELECTOR_TABLE {
fn_swap
fn_transfer
fn_approve
fn_balance
}
#define macro MAIN() = takes(0) returns(0) {
0x00 calldataload 0xe0 shr // [selector]
// Отображаем селектор в индекс (0-3)
0x18 shr // [index]
// Проверка границ
dup1 0x04 lt
valid jumpi
0x00 0x00 revert
valid:
__tablestart(SELECTOR_TABLE)
swap1 0x02 mul add
0x1e mload
jump
fn_swap:
SWAP_IMPL()
fn_transfer:
TRANSFER_IMPL()
fn_approve:
APPROVE_IMPL()
fn_balance:
BALANCE_IMPL()
}
Извлечение селектора из calldata
Стандартное извлечение селектора:
0x00 calldataload // загружает 32 байта из calldata позиции 0
0xe0 shr // сдвиг вправо 224 бита (256 - 32) для изоляции верхних 4 байт
Стоимость: PUSH1 (3) + CALLDATALOAD (3) + PUSH1 (3) + SHR (3) = 12 газа.
Более дешёвая альтернатива, когда нужен только 1-2 байта селектора:
0x00 calldataload // [calldata_word]
0xf8 shr // [first_byte] — сдвиг вправо 248 бит для получения байта 0
Тот же газ, но маршрутизирующий ключ — 1 байт (256 возможных значений). Если контракт имеет <= 256 функций, одного байта достаточно.
Сравнение газа
Бенчмарк стоимости диспатча для разного числа функций:
| Функций | Solidity (if-else) | Solidity (бинарный) | Huff Jump Table |
|---|---|---|---|
| 2 | 22-44 газа | 22-44 газа | 15 газа |
| 4 | 22-88 газа | 22-66 газа | 15 газа |
| 8 | 22-176 газа | 22-88 газа | 15 газа |
| 16 | 22-352 газа | 22-110 газа | 15 газа |
| 32 | 22-704 газа | 22-132 газа | 15 газа |
Стоимость jump-таблицы константна: CALLDATALOAD (3) + SHR (3) + арифметика (3-6) + JUMP (8) = ~15-18 газа. Не меняется независимо от числа функций.
Майнинг vanity-селекторов
Для работы jump-таблиц нужны селекторы с предсказуемыми маршрутизирующими байтами:
import hashlib
import itertools
target_byte = 0x00
base_name = "swap"
for suffix in itertools.count():
name = f"{base_name}{suffix}(uint256,address)"
selector = hashlib.sha3_256(name.encode()).digest()[:4]
if selector[0] == target_byte:
print(f"Found: {name} -> 0x{selector.hex()}")
break
На практике мы используем cast sig из Foundry или Rust-утилиту для перебора имён функций. Для контракта с 8 функциями майнинг 8 совместимых селекторов занимает миллисекунды.
Влияние на размер байткода
Размер байткода напрямую влияет на стоимость деплоя (200 газа за байт через CREATE):
| Подход | Рантайм-байткод | Газ деплоя |
|---|---|---|
| Solidity (8 функций) | ~800 байт | 160 000 газа |
| Huff jump table (8 функций) | ~200 байт | 40 000 газа |
| Huff минимальный (2 функции) | ~61 байт | 12 200 газа |
Для MEV-ботов, часто передеплоивающих контракты (ротация адресов через CREATE2), меньший байткод = меньшие операционные расходы.
Ограничения
- Нестандартный ABI — внешние инструменты (Etherscan, кошельки) не смогут декодировать calldata без кастомных ABI-определений.
- Майнинг селекторов — требует предварительной работы и ограничивает именование функций.
- Стоимость обслуживания — Huff сложнее аудитить и модифицировать, чем Solidity.
- Упакованные таблицы — встроенные
__tablestartи__tablesizeимеют граничные случаи; тщательно тестируйте.
Итоги
Jump-таблицы заменяют O(N) диспатч-цепочку Solidity на O(1) вычисленные переходы. Экономия газа накапливается за миллионы вызовов — значимое преимущество для высокочастотных контрактов вроде MEV-ботов и DEX-роутеров. В следующей статье мы исследуем продвинутые паттерны Huff: адаптивное исполнение и on-chain вычисления.