Перейти к основному содержимому
БлокчейнMar 28, 2026

Deep EVM #11: Jump-таблицы в Huff — O(1) диспатч без оверхеда Solidity

OS
Open Soft Team

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-х.

Концепция:

  1. Извлечь селектор функции из calldata (4 байта).
  2. Арифметически вычислить адрес перехода из селектора.
  3. 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
222-44 газа22-44 газа15 газа
422-88 газа22-66 газа15 газа
822-176 газа22-88 газа15 газа
1622-352 газа22-110 газа15 газа
3222-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), меньший байткод = меньшие операционные расходы.

Ограничения

  1. Нестандартный ABI — внешние инструменты (Etherscan, кошельки) не смогут декодировать calldata без кастомных ABI-определений.
  2. Майнинг селекторов — требует предварительной работы и ограничивает именование функций.
  3. Стоимость обслуживания — Huff сложнее аудитить и модифицировать, чем Solidity.
  4. Упакованные таблицы — встроенные __tablestart и __tablesize имеют граничные случаи; тщательно тестируйте.

Итоги

Jump-таблицы заменяют O(N) диспатч-цепочку Solidity на O(1) вычисленные переходы. Экономия газа накапливается за миллионы вызовов — значимое преимущество для высокочастотных контрактов вроде MEV-ботов и DEX-роутеров. В следующей статье мы исследуем продвинутые паттерны Huff: адаптивное исполнение и on-chain вычисления.