Deep EVM #11 : Tables de saut Huff — Dispatch de fonctions O(1) sans overhead Solidity
Engineering Team
Le problème du dispatcher de Solidity
Quand vous appelez un contrat Solidity, la première chose que l’EVM exécute est le dispatcher de fonctions. Solidity génère une chaîne if-else linéaire qui compare les 4 premiers octets du calldata (le sélecteur de fonction) à chaque sélecteur connu.
Pour un contrat avec N fonctions, c’est du O(N) — le pire cas vérifie tous les N sélecteurs. Chaque comparaison coûte environ 22 gas. Un contrat avec 20 fonctions gaspille jusqu’à 440 gas rien que sur le dispatch.
Pour un contrat de bot MEV appelé des millions de fois, ces 400+ unités de gas par appel s’accumulent en vrai ETH.
L’approche table de saut : O(1)
Une table de saut mappe un sélecteur de fonction directement à un offset de code en utilisant l’arithmétique, pas la comparaison. L’idée est empruntée à l’architecture CPU — les GOTO calculés sont utilisés depuis les années 1960.
Le concept :
- Extraire le sélecteur de fonction du calldata (4 octets).
- Utiliser l’arithmétique pour calculer une destination de saut à partir du sélecteur.
- Sauter directement à cette destination.
Pas de comparaisons, pas de branchements, temps constant quel que soit le nombre de fonctions.
Approche 1 : encodage de sélecteur minimal
Si votre contrat a un petit nombre de fonctions (1-8), vous pouvez assigner manuellement les sélecteurs en minant des sélecteurs vanity où le premier ou les deux premiers octets encodent un petit entier unique :
#define macro DISPATCHER() = takes(0) returns(0) {
0x00 calldataload // [calldata_word]
0xe0 shr // [selector]
// Extraire l'octet de routage — premier octet du sélecteur
0x18 shr // [first_byte]
// Chaque entrée dans notre table fait 2 octets (PUSH2 offset)
0x02 mul // [offset_in_table]
__tablestart(JumpTable) // [table_start, offset_in_table]
add // [entry_address]
// Charger la destination de saut de 2 octets depuis la table
0x00 codecopy // copier 2 octets du code vers la mémoire
0x00 mload // [jump_dest (rembourré à 32 octets)]
0xf0 shr // [jump_dest]
jump // GOTO corps de la fonction
}
Approche 2 : table de code compacte
Pour une densité maximale, compactez la table de saut directement dans le bytecode en utilisant __tablestart et __tablesize. Huff supporte nativement les tables de saut :
#define jumptable__packed SELECTOR_TABLE {
fn_swap
fn_transfer
fn_approve
fn_balance
}
Comparaison de gas
| Fonctions | Solidity (if-else) | Solidity (binaire) | Table de saut Huff |
|---|---|---|---|
| 2 | 22-44 gas | 22-44 gas | 15 gas |
| 4 | 22-88 gas | 22-66 gas | 15 gas |
| 8 | 22-176 gas | 22-88 gas | 15 gas |
| 16 | 22-352 gas | 22-110 gas | 15 gas |
| 32 | 22-704 gas | 22-132 gas | 15 gas |
Le coût de la table de saut est constant : CALLDATALOAD (3) + SHR (3) + arithmétique (3-6) + JUMP (8) = environ 15-18 gas. Il ne change jamais, quel que soit le nombre de fonctions.
Minage de sélecteurs vanity
Pour que l’approche table de saut fonctionne, vous avez besoin de sélecteurs de fonction avec des octets de routage prévisibles. Vous pouvez les miner :
import hashlib
import itertools
target_byte = 0x00 # premier octet désiré du sélecteur
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"Trouvé: {name} -> 0x{selector.hex()}")
break
En pratique, nous utilisons cast sig de Foundry ou un outil Rust qui brute-force les noms de fonctions pour trouver des sélecteurs avec les préfixes désirés.
Impact sur la taille du bytecode
La taille du bytecode affecte directement le coût de déploiement (200 gas par octet via CREATE). Comparaison :
| Approche | Bytecode runtime | Gas de déploiement |
|---|---|---|
| Solidity (8 foncs) | ~800 octets | 160 000 gas |
| Table de saut Huff (8 foncs) | ~200 octets | 40 000 gas |
| Huff minimal (2 foncs) | ~61 octets | 12 200 gas |
Limitations
- ABI non standard — Les outils externes (Etherscan, portefeuilles) ne peuvent pas décoder vos calldata sans définitions ABI personnalisées.
- Minage de sélecteurs — Nécessite un travail initial et contraint le nommage des fonctions.
- Coût de maintenance — Huff est plus difficile à auditer et modifier que Solidity.
Résumé
Les tables de saut remplacent la chaîne de dispatch O(N) de Solidity par des sauts calculés O(1). Les économies de gas se cumulent sur des millions d’appels — un avantage significatif pour les contrats haute fréquence. Dans le prochain article, nous explorerons les patterns Huff avancés : exécution adaptative, authentification multi-opérateur et astuces de disposition mémoire.