跳到主要内容
BlockchainMar 28, 2026

Deep EVM #11: Huff Jump Tables — O(1) Function Dispatch Without Solidity Overhead

OS
Open Soft Team

Engineering Team

The Problem with Solidity’s Dispatcher

When you call a Solidity contract, the first thing the EVM executes is the function dispatcher. Solidity generates a linear if-else chain that compares the calldata’s first 4 bytes (the function selector) against each known selector:

CALLDATALOAD 0x00
SHR 224
DUP1
PUSH4 0x70a08231    // balanceOf(address)
EQ
PUSH2 dest1
JUMPI
DUP1
PUSH4 0xa9059cbb    // transfer(address,uint256)
EQ
PUSH2 dest2
JUMPI
...

For a contract with N functions, this is O(N) — the worst case checks all N selectors before finding a match. Each comparison costs roughly 22 gas (DUP1 + PUSH4 + EQ + PUSH2 + JUMPI). A contract with 20 functions wastes up to 440 gas just on dispatch. Solidity’s compiler (since 0.8.22) sometimes uses a binary search tree for large interfaces, bringing it to O(log N), but this is still suboptimal.

For an MEV bot contract that gets called millions of times, those 400+ gas units per call add up to real ETH.

The Jump Table Approach: O(1)

A jump table maps a function selector directly to a code offset using arithmetic, not comparison. The idea is borrowed from CPU architecture — computed GOTOs have been used in systems programming since the 1960s.

The concept:

  1. Extract the function selector from calldata (4 bytes).
  2. Use arithmetic to compute a jump destination from the selector.
  3. JUMP directly to that destination.

No comparisons, no branching, constant time regardless of how many functions exist.

Approach 1: Minimal Selector Encoding

If your contract has a small number of functions (1-8), you can assign selectors manually by mining vanity selectors where the first byte or two encode a unique small integer:

#define macro DISPATCHER() = takes(0) returns(0) {
    0x00 calldataload       // [calldata_word]
    0xe0 shr                // [selector]

    // Extract routing byte — first byte of selector
    0x18 shr                // [first_byte]

    // Each entry in our table is 2 bytes (PUSH2 offset)
    // Table start + first_byte * 2 = jump destination pointer
    0x02 mul                // [offset_in_table]
    __tablestart(JumpTable) // [table_start, offset_in_table]
    add                     // [entry_address]
    
    // Load the 2-byte jump destination from the table
    codecopy_dest:
    0x00 codecopy           // copy 2 bytes from code to memory
    0x00 mload              // [jump_dest (padded to 32 bytes)]
    0xf0 shr                // [jump_dest] — shift right to get 2-byte value
    jump                    // GOTO function body
}

#define jumptable JumpTable {
    swap_exact   // selector 0x00...
    add_liq      // selector 0x01...
    remove_liq   // selector 0x02...
    flash_loan   // selector 0x03...
}

Approach 2: Packed Code Table

For maximum density, you can pack the jump table directly into the bytecode using __tablestart and __tablesize. Huff natively supports jump tables as first-class constructs:

#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]

    // Map selector to index (0-3)
    // This depends on your selector values — you mine them
    // so the top byte is the index
    0x18 shr                    // [index]

    // Bounds check
    dup1 0x04 lt                // [in_bounds?, index]
    valid jumpi
    0x00 0x00 revert

    valid:
    // Load from packed table (2 bytes per entry)
    __tablestart(SELECTOR_TABLE)
    swap1 0x02 mul add          // [table_entry_ptr]

    // Read 2-byte code offset
    0x1e mload                  // trick: mload from code via codecopy
    jump

    fn_swap:
        SWAP_IMPL()
    fn_transfer:
        TRANSFER_IMPL()
    fn_approve:
        APPROVE_IMPL()
    fn_balance:
        BALANCE_IMPL()
}

Calldata Bit-Shifting for Selector Extraction

The standard selector extraction is:

0x00 calldataload   // loads 32 bytes from calldata position 0
0xe0 shr            // shift right 224 bits (256 - 32) to isolate top 4 bytes

This costs: PUSH1 (3) + CALLDATALOAD (3) + PUSH1 (3) + SHR (3) = 12 gas.

A cheaper alternative when you only need 1-2 bytes of the selector:

0x00 calldataload   // [calldata_word]
0xf8 shr            // [first_byte] — shift right 248 bits to get just byte 0

Same gas, but now your routing key is 1 byte (256 possible values). If your contract has <= 256 functions, one byte is enough to uniquely identify each. You mine vanity selectors (using cast selectors or a custom script) so that each function’s first selector byte is unique.

Real Example: A 61-Byte DEX Router

Here is a minimal swap contract that routes between two operations — swapExactIn and swapExactOut — in just 61 bytes of runtime bytecode:

#define constant POOL = 0x...       // Uniswap V2 pair address
#define constant TOKEN0 = 0x...     // token0 address
#define constant TOKEN1 = 0x...     // token1 address

#define macro MAIN() = takes(0) returns(0) {
    // Selector: 1 byte at calldata[0]
    0x00 calldataload 0xf8 shr  // [route_byte]

    // route_byte == 0 → swapExactIn, else → swapExactOut
    swap_out jumpi

    // --- swapExactIn ---
    SWAP_EXACT_IN()
    stop

    swap_out:
    SWAP_EXACT_OUT()
    stop
}

#define macro SWAP_EXACT_IN() = takes(0) returns(0) {
    // Read amount from calldata[1..33]
    0x01 calldataload           // [amountIn]

    // Transfer token0 to pool
    // ... ERC20 transfer calldata assembly ...

    // Call pool.swap(amountOut, 0, address(this), "")
    // ... swap calldata assembly ...
}

The entire dispatcher is 7 bytes: PUSH1 0x00 CALLDATALOAD PUSH1 0xf8 SHR PUSH2 dest JUMPI. Compare that to Solidity’s 40+ byte dispatcher.

Gas Comparison

Let us benchmark dispatch cost for varying function counts:

FunctionsSolidity (if-else)Solidity (binary)Huff Jump Table
222-44 gas22-44 gas15 gas
422-88 gas22-66 gas15 gas
822-176 gas22-88 gas15 gas
1622-352 gas22-110 gas15 gas
3222-704 gas22-132 gas15 gas

The jump table cost is constant: CALLDATALOAD (3) + SHR (3) + arithmetic (3-6) + JUMP (8) = ~15-18 gas. It never changes regardless of function count.

Mining Vanity Selectors

For the jump table approach to work, you need function selectors with predictable routing bytes. You can mine these:

import hashlib
import itertools

target_byte = 0x00  # desired first byte of selector
base_name = "swap"

for suffix in itertools.count():
    name = f"{base_name}{suffix}(uint256,address)"
    selector = hashlib.sha3_256(name.encode()).digest()[:4]  # keccak256
    if selector[0] == target_byte:
        print(f"Found: {name} -> 0x{selector.hex()}")
        break

In practice, we use cast sig from Foundry or a Rust tool that brute-forces function names to find selectors with desired prefixes. For a contract with 8 functions, mining 8 compatible selectors takes milliseconds.

Bytecode Size Impact

Bytecode size directly affects deployment cost (200 gas per byte via CREATE, 32,000 base). A comparison:

ApproachRuntime BytecodeDeploy Gas (runtime only)
Solidity (8 funcs)~800 bytes160,000 gas
Huff jump table (8 funcs)~200 bytes40,000 gas
Huff minimal (2 funcs)~61 bytes12,200 gas

For MEV bots that redeploy contracts frequently (to rotate addresses or update parameters via CREATE2), smaller bytecode directly translates to lower operational costs.

Limitations

  1. Non-standard ABI — External tools (Etherscan, wallets) cannot decode your calldata without custom ABI definitions.
  2. Selector mining — Requires upfront work and constrains function naming.
  3. Maintenance cost — Huff is harder to audit and modify than Solidity.
  4. Packed tables — The __tablestart and __tablesize builtins have edge cases with the Huff compiler; test thoroughly.

Summary

Jump tables replace Solidity’s O(N) dispatch chain with O(1) computed jumps. The gas savings compound across millions of calls — a meaningful advantage for high-frequency contracts like MEV bots and DEX routers. Combined with vanity selector mining, you can build contracts where the dispatch overhead is under 20 gas regardless of interface size. In the next article, we will explore advanced Huff patterns: adaptive execution, multi-operator auth, and memory layout tricks.