- Optimisation sur Ethereum : Faites la différence avec les noms de fonctions
- L'optimisation des coûts en gas est cruciale pour les contrats intelligents sur Ethereum.
- Le "function dispatcher" gère l'exécution des fonctions dans les smart contracts pour les EVMs.
- Le compilateur Solidity génère le "function dispatcher" des fonctions exposées publiquement, alors qu'en Yul cela doit être codé.
- Les signatures, hashs et empreintes des fonctions sont déterminés par leurs noms et types de paramètres.
- Le réglage d'optimisation du compilateur et le nombre de fonctions impactent l'algorithme de sélection des fonctions.
- Le renommage stratégique des fonctions optimise les coûts en gas et l'ordre de sélection, de par les valeurs des empreintes.
L'optimisation des coûts en gas est un enjeu clé dans le développement de contrats intelligents sur la blockchain Ethereum, chaque opération effectuée sur Ethereum ayant un coût en gas. Cet article sera aussi l'occasion de fournir du contenu aux lecteurs francophones.
Rappel :
- Le bytecode représente un smart contract sur la blockchain sous forme d'une séquence d'hexadécimaux.
- La machine virtuelle Ethereum (EVM) exécute les instructions en lisant ce bytecode lors de l'interaction avec le contrat.
- Chaque instruction élémentaire, codée sur un octet, est appelée opcode et a un coût en gas qui reflète les ressources nécessaires à son exécution.
- Un compilateur traduit ce code source en bytecode exécutable par l'EVM et fournit des éléments tels que l'ABI (interface binaire d'application).
- Une ABI définit comment les fonctions d'un contrat doivent être appelées et les données échangées, en spécifiant les types de données des arguments et la signature des fonctions.
Dans cet article, nous allons explorer comment le simple fait de nommer vos fonctions peut influencer les coûts en gas associés à votre contrat.
Nous discuterons également de diverses stratégies d'optimisation, de l'ordre des hash de signatures aux astuces de renommage des fonctions, afin de réduire les coûts associés aux interactions avec vos contrats.
Précisions :
Cette article se base sur :
- Du code Solidity (0.8.13, 0.8.17, 0.8.20, 0.8.22)
- Compilé avec le compilateur
solc
- Pour des EVMs sur Ethereum
Les concepts suivants seront abordés :
- L'empreinte : l'identitifiant numérique d'une fonction au sein de l'EVM.
- Le "function dispatcher" : le mécanisme de sélection d'une fonction dans un contrat.
- Et le nom de fonction en tant qu'argument (du côté de l'appelant).
La signature d'une fonction tel qu'employée avec les EVMs (Solidity) consiste en la concaténation de son nom et de ses types de paramètres (sans type de retour ni espaces)
L'empreinte ("function selector" dans les publications anglo-saxonnes) est l'empreinte même de la fonction qui la rend "unique" et identifiable. Dans le cas de Solidity, il s'agit des 4 octets de poids fort (32 bits) du résultat du hachage de la signature de la fonction avec l'algorithme Keccak-256 (🇬🇧).
Cela selon les spécifications de l'ABI en Solidity (🇬🇧).
Je précise à nouveau que je parle de l'empreinte pour le compilateur solc pour Solidity, ce n'est pas forcément le cas avec d'autres langages comme Rust qui fonctionne sur un tout autre paradigme.
Si les types des paramètres sont pris en compte, c'est pour différencier les fonctions qui auraient le même nom, mais des paramètres différents, comme pour la méthode safeTransferFrom
des tokens ERC721 (🇬🇧).
Cependant, le fait que l'on ne garde que quatre octets pour l'empreinte, implique de potentiels risques de collisions de hash entre deux fonctions, risque rare mais existant malgré plus de 4 milliards de possibilités (2^32).
Comme en atteste le site Ethereum Signature Database (🇬🇧) avec l'exemple suivant :
Empreintes | Signatures |
---|---|
0xcae9ca51 |
onHintFinanceFlashloan(address,address,uint256,bool,bytes) |
0xcae9ca51 |
approveAndCall(address,uint256,bytes) |
Un simple contrat Solidity doté de ces deux fonctions ne se compile heureusement pas.
TypeError: Function signature hash collision for approveAndCall(address,uint256,bytes)
--> contracts/HashCollision.sol:10:1:
|
10 | contract HashCollision {
| ^ (Relevant source part starts here and spans across multiple lines).
Mais cela n'en demeure pas moins problématique : Voir le challenge Hint-finance, au Web3 Hacking: Paradigm CTF 2022 (🇬🇧)
Le "function dispatcher" (ou gestionnaire de fonctions) dans les smart contracts (contrats intelligents) écrits pour les EVMs est un élément du contrat qui permet de déterminer quelle fonction doit être exécutée lorsque quelqu'un interagit avec le contrat au travers d'une ABI.
En résumé, le "function dispatcher" est comme un chef d'orchestre lors des appels aux fonctions d'un contrat intelligent. Il garantit que les bonnes fonctions sont appelées lorsque vous effectuez les bonnes actions sur le contrat.
Lorsque vous interagissez avec un contrat intelligent via une transaction, vous spécifiez quelle fonction vous souhaitez exécuter. Le "function dispatcher" fait donc le lien entre la commande et la fonction spécifique qui sera appelée.
L'empreinte de la fonction est récupérée dans le calldata
lors de l'éxécution du contrat, un revert
se produit si l'appel ne peut être mis en relation avec une fonction du contrat.
Le mécanisme de sélection est similaire, à un celui d'une structure switch/case
ou d'un ensemble de if/else
.
En mettant en application ce qui a été dit plus haut, on obtient, pour la fonction suivante :
function square(uint32 num) public pure returns (uint32) {
return num * num;
}
Les signature, hash et empreintes suivants :
Fonction | square(uint32 num) public pure returns (uint32) |
---|---|
Signature | square(uint32) (1) |
Hash | d27b38416d4826614087db58e4ea90ac7199f7f89cb752950d00e21eb615e049 |
Identité | d27b3841 |
(1) : Keccak-256 online calculator : square(uint32)
En Solidity, le "function dispatcher" est généré par le compilateur, inutile donc de se charger du codage de cette tâche complexe.
Il ne concerne que les fonctions d'un contrat ayant un accès depuis l'extérieur de celui-ci, ayant donc un attribut d'accès external et public
-
External : Les fonctions externes sont conçues pour être appelées depuis l'extérieur du contrat, généralement par d'autres contrats ou des comptes externes. C'est la visibilité pour exposer une interface publique à votre contrat.
-
Public : Les fonctions publiques sont accessibles depuis l'extérieur et l'intérieur du contrat.
-
Internal et private : Les fonctions internes et private ne peuvent être appelées que depuis l'intérieur du contrat (et les contrants héritant de celui-ci dans le cas d'internal).
Exemple #1 :
pragma solidity 0.8.13;
contract MyContract {
uint256 public value;
uint256 internalValue;
function setValue(uint256 _newValue) external {
value = _newValue;
}
function getValue() public view returns (uint256) {
return value;
}
function setInternalValue(uint256 _newValue) internal {
internalValue = _newValue;
}
function getInternalValue() public view returns (uint256) {
return internalValue;
}
}
Si nous reprenons le précédent code utilisé en exemple, nous obtenons les signatures et empreintes suivantes :
Fonctions | Signatures | Keccak | Empreintes |
---|---|---|---|
setValue(uint256 _newValue) external |
setValue(uint256) |
55241077...ecbd |
55241077 |
getValue() public view returns (uint256) |
getValue() |
20965255...ad96 |
20965255 |
setInternalValue(uint256 _newValue) internal |
setInternalValue(uint256) |
6115694f...7ce1 |
6115694f |
getInternalValue() public view returns (uint256) |
getInternalValue() |
e778ddc1...c094 |
e778ddc1 |
(Les hashs issus du Keccak ont été tronqués volontairement)
Si on examine l'ABI généré lors de la compilation, la fonction setInternalValue()
n'apparaît pas, ce qui est normal, sa visibilité étant internal
. (voir plus haut)
On notera dans les données de l'ABI, la référence à la donnée du storage value
qui est public
(on y reviendra plus loin)
Voici, en extrait le code du "function dispatcher" généré par le compilateur solc
(version de Solidity : 0.8.13) on y voit que valeur numérique de l'empreinte est récupérée dans le calldata
puis que cette valeur est comparée aux différentes signatures des fonctions permettant de "sauter" au code de la fonction souhaitée.
tag 1
JUMPDEST
POP
PUSH 4
CALLDATASIZE
LT
PUSH [tag] 2
JUMPI
PUSH 0
CALLDATALOAD
PUSH E0
SHR
DUP1
PUSH 20965255 // ◄ signature : getValue()
EQ
PUSH [tag] getValue_0
JUMPI
DUP1
PUSH 3FA4F245 // ◄ signature : value (automatic storage getters)
EQ
PUSH [tag] 4
JUMPI
DUP1
PUSH 55241077 // ◄ signature : setValue(uint256)
EQ
PUSH [tag] setValue_uint256_0
JUMPI
DUP1
PUSH E778DDC1 // ◄ signature : getInternalValue()
EQ
PUSH [tag] getInternalValue_0
JUMPI
tag 2
JUMPDEST
PUSH 0
DUP1
REVERT
Sous forme de diagramme, on comprend mieux le mécanisme de sélection similaire à un celui d'une structure switch/case
ou d'un ensemble de if/else
.
Important : L'ordre d'évaluation des fonctions n'est pas le même que celui de déclaration dans le code !
Ordre d'évaluation | Ordre dans le code | Empreintes | Signatures |
---|---|---|---|
1 | 3 | 20965255 |
getValue() |
2 | 1 | 3FA4F245 |
value (getter automatique) |
3 | 2 | 55241077 |
setValue(uint256) |
4 | 4 | E778DDC1 |
getInternalValue() |
En effet, les évaluations des empreintes de fonctions sont ordonnées par un tri ascendant sur leurs valeurs.
20965255
< 3FA4F245
< 55241077
< E778DDC1
La fonction d'empreinte 3FA4F245
est en fait un getter automatique de la donnée publique value
, elle est générée par le compilateur. En solidty, le compilateur fournit automatiquement un getter public à toute variable de storage publique.
uint256 public value;
Nous retrouvons d'ailleurs dans les opcodes, l'empreinte de sélection (3FA4F245
) et la fonction (à l'adresse tag 4
) du getter automatique pour cette variable.
Sélecteur :
DUP1
PUSH 3FA4F245
EQ
PUSH [tag] 4
JUMPI
Fonction :
tag 4
JUMPDEST
PUSH [tag] 11
PUSH [tag] 12
JUMP [in]
tag 11
JUMPDEST
PUSH 40
MLOAD
PUSH [tag] 13
SWAP2
SWAP1
PUSH [tag] abi_encode_tuple_t_uint256__to_t_uint256__fromStack_reversed_0
JUMP [in]
tag 13
JUMPDEST
PUSH 40
MLOAD
DUP1
SWAP2
SUB
SWAP1
RETURN
getter
ayant d'ailleurs un code identique à celui de la fonction getValue()
tag getValue_0
JUMPDEST
PUSH [tag] getValue_1
PUSH [tag] getValue_3
JUMP [in]
tag getValue_1
JUMPDEST
PUSH 40
MLOAD
PUSH [tag] getValue_2
SWAP2
SWAP1
PUSH [tag] abi_encode_tuple_t_uint256__to_t_uint256__fromStack_reversed_0
JUMP [in]
tag getValue_2
JUMPDEST
PUSH 40
MLOAD
DUP1
SWAP2
SUB
SWAP1
RETURN
Démontrant ainsi l'inutilité d'avoir la variable value
avec l'attribut public
de concert avec la fonction getValue()
, mais également une faiblesse du compilateur de Solidity solc
qui ne peut fusionner le code des deux fonctions.
Voici d'ailleurs un lien, pour ceux qui voudraient aller plus loin, un article détaillé (🇬🇧) sur les automatic storage getters
en Solidity.
Voici un extrait d'un exemple de contrat ERC20 (🇬🇧) entièrement écrit en Yul.
Si Solidity apporte abstraction et lisibilité, Yul langage de plus bas niveau, proche de l'assembleur, permet d'avoir un bien meilleur contrôle de l'exécution.
object "runtime" {
code {
// Protection against sending Ether
require(iszero(callvalue()))
// Dispatcher
switch selector()
case 0x70a08231 /* "balanceOf(address)" */ {
returnUint(balanceOf(decodeAsAddress(0)))
}
case 0x18160ddd /* "totalSupply()" */ {
returnUint(totalSupply())
}
case 0xa9059cbb /* "transfer(address,uint256)" */ {
transfer(decodeAsAddress(0), decodeAsUint(1))
returnTrue()
}
case 0x23b872dd /* "transferFrom(address,address,uint256)" */ {
transferFrom(decodeAsAddress(0), decodeAsAddress(1), decodeAsUint(2))
returnTrue()
}
case 0x095ea7b3 /* "approve(address,uint256)" */ {
approve(decodeAsAddress(0), decodeAsUint(1))
returnTrue()
}
case 0xdd62ed3e /* "allowance(address,address)" */ {
returnUint(allowance(decodeAsAddress(0), decodeAsAddress(1)))
}
case 0x40c10f19 /* "mint(address,uint256)" */ {
mint(decodeAsAddress(0), decodeAsUint(1))
returnTrue()
}
default {
revert(0, 0)
}
/* ---------- calldata decoding functions ----------- */
function selector() -> s {
s := div(calldataload(0), 0x100000000000000000000000000000000000000000000000000000000)
}
...
On y retrouve la suite de structure de if/else
en cascade, identique au diagramme précédent.
Réaliser un contrat 100% en Yul, oblige à coder soi-même le "function dispatcher", ce qui implique que l'on peut choisir l'ordre de traitement des empreintes, ainsi qu'utiliser d'autres algorithmes qu'une simple suite de tests en cascade.
Maintenant, voici un tout autre exemple pour illustrer le fait que les choses sont plus complexes en réalité !
Car en fonction du nombre de fonctions et du niveau d'optimisation (voir : --optimize-runs
) le compilateur Solidity n'a pas le même comportement !
Exemple #2 :
// SPDX-License-Identifier: GPL-3.0
pragma solidity 0.8.17;
contract Storage {
uint256 numberA;
uint256 numberB;
uint256 numberC;
uint256 numberD;
uint256 numberE;
// selector : C534BE7A
function storeA(uint256 num) public {
numberA = num;
}
// selector : 9AE4B7D0
function storeB(uint256 num) public {
numberB = num;
}
// selector : 4CF56E0C
function storeC(uint256 num) public {
numberC = num;
}
// selector : B87C712B
function storeD(uint256 num) public {
numberD = num;
}
// selector : E45F4CF5
function storeE(uint256 num) public {
numberE = num;
}
// selector : 2E64CEC1
function retrieve() public view returns (uint256) {
return Multiply( numberA, numberB, numberC, numberD);
}
}
Ici les variables de storage
sont internal
(attribut par défaut en Solidity) aucun getter automatique ne sera donc ajouté par le compilateur.
Et nous avons bien 6 fonctions présentes dans le JSON de l'ABI. Les 6 fonctions public
suivantes avec leurs empreintes dédiées :
Fonctions | Signatures | Empreintes |
---|---|---|
storeA(uint256 num) public |
storeA(uint256) |
C534BE7A |
storeB(uint256 num) public |
storeB(uint256) |
9AE4B7D0 |
storeC(uint256 num) public |
storeC(uint256) |
4CF56E0C |
storeD(uint256 num) public |
storeD(uint256) |
B87C712B |
storeE(uint256 num) public |
storeE(uint256) |
E45F4CF5 |
retrieve() public view returns (uint256) |
retrieve() |
2E64CEC1 |
Suivant le niveau d'optimisation (🇬🇧) du compilateur, nous obtenons un code différent pour le "function dispatcher".
Avec un niveau à 200 (--optimize-runs 200
) nous obtenons le type de code précédemment généré, avec ses if/else
en cascade.
tag 1
JUMPDEST
POP
PUSH 4
CALLDATASIZE
LT
PUSH [tag] 2
JUMPI
PUSH 0
CALLDATALOAD
PUSH E0
SHR
DUP1
PUSH 2E64CEC1
EQ
PUSH [tag] retrieve_0
JUMPI
DUP1
PUSH 4CF56E0C
EQ
PUSH [tag] storeC_uint256_0
JUMPI
DUP1
PUSH 9AE4B7D0
EQ
PUSH [tag] storeB_uint256_0
JUMPI
DUP1
PUSH B87C712B
EQ
PUSH [tag] storeD_uint256_0
JUMPI
DUP1
PUSH C534BE7A
EQ
PUSH [tag] storeA_uint256_0
JUMPI
DUP1
PUSH E45F4CF5
EQ
PUSH [tag] storeE_uint256_0
JUMPI
PUSH 0
DUP1
REVERT
Par contre, avec un niveau de runs
plus élevé (--optimize-runs 300
)
tag 1
JUMPDEST
POP
PUSH 4
CALLDATASIZE
LT
PUSH [tag] 2
JUMPI
PUSH 0
CALLDATALOAD
PUSH E0
SHR
DUP1
PUSH B87C712B
GT
PUSH [tag] 9
JUMPI
DUP1
PUSH B87C712B
EQ
PUSH [tag] storeD_uint256_0
JUMPI
DUP1
PUSH C534BE7A
EQ
PUSH [tag] storeA_uint256_0
JUMPI
DUP1
PUSH E45F4CF5
EQ
PUSH [tag] storeE_uint256_0
JUMPI
PUSH 0
DUP1
REVERT
tag 9
JUMPDEST
DUP1
PUSH 2E64CEC1
EQ
PUSH [tag] retrieve_0
JUMPI
DUP1
PUSH 4CF56E0C
EQ
PUSH [tag] storeC_uint256_0
JUMPI
DUP1
PUSH 9AE4B7D0
EQ
PUSH [tag] storeB_uint256_0
JUMPI
tag 2
JUMPDEST
PUSH 0
DUP1
REVERT
Les opcodes et le flux d'exécution avec --optimize-runs 300
, ne sont plus les mêmes, comme montré dans le diagramme suivant.
On voit que les tests sont "découpés" en deux recherches linéaires autour d'une valeur pivot B87C712B
, diminuant ainsi la consommation pour les cas les moins favorables storeB(uint256)
et storeE(uint256)
.
Seulement 4 tests pour les fonctions storeB(uint256)
et storeE(uint256)
, au lieu de, respectivement, 3 tests et 6 tests avec le précédent algorithme.
La détermination du déclenchement de ce type d'optimisation est plus délicat, par exemple le seuil du nombre de fonctions se trouve être 6 pour le déclencher avec --optimize-runs 284
, donnant deux tranches de 3 séries de tests linéaires.
Lorsque le nombre de fonctions est inférieur à 4, le processus de sélection se fait par une recherche linéaire. En revanche, à partir de cinq fonctions, le compilateur fractionne le traitement en fonction de son paramètre d'optimisation.
Des tests sur des contrats basiques comportant de 4 à 15 fonctions, avec des optimisations de 200 à 1000 exécutions, ont démontré ces seuils.
Le tableau suivant (qui résulte de ces tests) nous montre le nombre de fractionnements en indiquant le nombre de recherches linéaires.
Relevé du nombre de séquences linéaires en fonction du runs level et de la quantité de fonctions
( F : Nbr functions / R : Runs level )
Ces seuils (liés à des valeurs de runs
) sont-t-il susceptibles d'évoluer au fil des versions du compilateur solc
?
Détaillons un exemple pour le cas d'un contrat avec 11 fonctions pour visualiser l'impact sur la consommation en gas.
Avec 11 fonctions éligibles, et un niveau de runs
supérieur --optimize-runs 1000
on passe de deux tranches (une de 6 + une de 5) à 4 tranches (trois tranches de 3 + une de 2)
Cette fois-ci, je ne reproduis pas les opcodes et le diagramme associé, afin de clarifier l'explication, voici le flux d'exécution sous forme de pseudo-code, semblable à du code en langage C.
// [tag 1]
// 1 gas (JUMPDEST)
if( selector >= 0x799EBD70) { // 22 = (3+3+3+3+10) gas
if( selector >= 0xB9E9C35C) { // 22 = (3+3+3+3+10) gas
if( selector == 0xB9E9C35C) { goto storeF } // 22 = (3+3+3+3+10) gas
if( selector == 0xC534BE7A) { goto storeA } // 22 = (3+3+3+3+10) gas
if( selector == 0xE45F4CF5) { goto storeE } // 22 = (3+3+3+3+10) gas
revert()
}
// [tag 15]
// 1 gas (JUMPDEST)
if( selector == 0x799EBD70) { goto storeG } // 22 = (3+3+3+3+10) gas
if( selector == 0x9AE4B7D0) { goto storeB } // 22 = (3+3+3+3+10) gas
if( selector == 0xB87C712B) { goto storeD } // 22 = (3+3+3+3+10) gas
revert()
} else {
// [tag 14]
// 1 gas (JUMPDEST)
if( selector >= 0x4CF56E0C) { // 22 = (3+3+3+3+10) gas
if( selector == 0x4CF56E0C) { goto storeC } // 22 = (3+3+3+3+10) gas
if( selector == 0x6EC51CF6) { goto storeJ } // 22 = (3+3+3+3+10) gas
if( selector == 0x75A64B6D) { goto storeH } // 22 = (3+3+3+3+10) gas
revert()
}
// [tag 16]
// 1 gas (JUMPDEST)
if( selector == 0x183301E7) { goto storeI } // 22 = (3+3+3+3+10) gas
if( selector == 0x2E64CEC1) { goto retrieve } // 22 = (3+3+3+3+10) gas
revert()
}
On distingue mieux les articulations autour des différentes valeurs "pivots" :
- Avec
799EBD70
en première valeur pivot. - Puis
0x4CF56E0C
&0xB9E9C35C
en valeurs pivot secondaires.
J'ai pris pour référence toujours le code d'un contrat Solidity avec 11 fonctions éligibles au "function dispatcher", afin d'estimer le coût en gas de la sélection, selon que l'on ait une recherche linéaire ou fractionnée.
C'est uniquement le coût de la sélection dans le "function dispatcher" et non l'exécution des fonctions qui est estimé. Nous ne nous préoccupons pas de ce que fait la fonction elle-même ni de ce qu'elle consomme comme gas, ni du code qui extrait l'empreinte de la fonction an allant chercher la donnée dans la zone calldata
.
L'estimation des coûts en gas des opcodes utilisés a été réalisée en m'aidant des sites suivants :
- Ethereum Yellow Paper (Berlin version, 🇬🇧)
- EVM Codes - An Ethereum Virtual Machine Opcodes Interactive Reference (🇬🇧)
Les opcodes en jeu pour ce qui nous concerne sont les suivants :
Mnemonic | Gas | Description |
---|---|---|
JUMPDEST |
1 | Mark valid jump destination. |
DUP1 |
3 | Clone 1st value on stack |
PUSH4 0xXXXXXXXX |
3 | Push 4-byte value onto stack. |
GT |
3 | Greater-than comparison. |
EQ |
3 | Equality comparison. |
PUSH [tag] |
3 | Push 2-byte value onto stack. |
JUMPI |
10 | Conditionally alter the program counter |
Ce qui m'a permis d'estimer les coûts en gas de recherche pour chaque fonction, pour les valeur de runs 200
et 1000
amenant ainsi un traitement différent, séquentiel pour 200 runs
et "fractionné" pour 1000 runs
.
Signatures | Empreintes | Gas (linear) | Gas (splited) |
---|---|---|---|
storeI(uint256) |
183301E7 |
22 (min) | 69 |
retrieve() |
2E64CEC1 |
44 | 91 |
storeC(uint256) |
4CF56E0C (2) |
66 | 69 |
storeJ(uint256) |
6EC51CF6 |
88 | 90 |
storeH(uint256) |
75A64B6D |
110 | 112 (max) |
storeG(uint256) |
799EBD70 (1) |
132 | 68 |
storeB(uint256) |
9AE4B7D0 |
154 | 90 |
storeD(uint256) |
B87C712B |
176 | 112 (max) |
storeF(uint256) |
B9E9C35C (2) |
198 | 67 (min) |
storeA(uint256) |
C534BE7A |
220 | 89 |
storeE(uint256) |
E45F4CF5 |
242 (max) | 111 |
- (1) : première valeur pivot pour 1000 runs
- (2) : valeurs pivot secondaires pour 1000 runs
Si on regarde d'un peu plus près le résultat de certaines statistiques sur les deux types de recherche.
\ | Linear | Splited |
---|---|---|
Min | 22 | 67 |
Max | 242 | 112 |
Moyenne | 132 | 88 |
Ecart-type | 72,97 | 18,06 |
On constate des différences notables. En l'occurrence, une moyenne plus basse (-33%) avec une dispersion des consommations considérablement plus faible (4 fois moins) en faveur du traitement par fractions.
Suivant l'algorithme utilisé par le compilateur Solidity pour générer le "function dispatcher", l'ordre de traitement des fonctions sera différent, aussi bien de l'ordre de déclaration dans le code source que de l'ordre alphabétique.
# | Signatures | Empreintes |
---|---|---|
1 | storeI(uint256) |
183301E7 |
2 | retrieve() |
2E64CEC1 |
3 | storeC(uint256) |
4CF56E0C |
4 | storeJ(uint256) |
6EC51CF6 |
5 | storeH(uint256) |
75A64B6D |
6 | storeG(uint256) |
799EBD70 |
7 | storeB(uint256) |
9AE4B7D0 |
8 | storeD(uint256) |
B87C712B |
9 | storeF(uint256) |
B9E9C35C |
10 | storeA(uint256) |
C534BE7A |
11 | storeE(uint256) |
E45F4CF5 |
Le nombre de tests et la complexité du processus sont proportionnels au nombre de fonctions, en O(n).
# | Signatures | Empreintes |
---|---|---|
1 | storeF(uint256) |
B9E9C35C |
2 | storeG(uint256) |
799EBD70 |
3 | storeI(uint256) |
183301E7 |
4 | storeC(uint256) |
4CF56E0C |
5 | storeA(uint256) |
C534BE7A |
6 | storeJ(uint256) |
6EC51CF6 |
7 | storeB(uint256) |
9AE4B7D0 |
8 | retrieve() |
2E64CEC1 |
9 | storeE(uint256) |
E45F4CF5 |
10 | storeH(uint256) |
75A64B6D |
11 | storeD(uint256) |
B87C712B |
Il ne s'agit pas d'une recherche dichotomique au sens strict du terme, mais plutôt d'un découpage en groupes de tests séquentiels autour de valeurs pivots. Mais au final, la complexité est identique, en O(log n).
Si on part sur le principe que les fonctions sont appelées de manière équitable (à la même fréquence d'utilisation) celles-ci, lors de leurs appels, ne coûteront pas la même chose en fonction de leurs signatures (et par là même de leurs noms). On voit clairement que tel quel le coût de la sélection d'un appel vers ces fonctions, quel que soit l'algorithme, est très hétérogène et s'il peut être estimé, il ne peut être imposé.
Cependant, en renommant stratégiquement les fonctions, en ajoutant des suffixes (par exemple) vous pouvez influencer le résultat des signatures de fonctions et, par conséquent, les coûts de gaz associés à ces fonctions. Cette pratique peut permettre d'optimiser la consommation de gaz dans votre contrat intelligent, lors de la sélection de la fonction dans l'EVM, mais aussi, comme nous le verrons plus loin, lors des transactions.
Le coût d'une transaction est constitué de deux parties : Le coût intrinsèque (dont ceux liés aux données utiles des transactions) et le coût d'exécution. Nos optimisations portent sur ces deux coûts.
Vous trouverez plus d'informations sur la répartition des coûts d'une transaction sur cette page (🇬🇧).
Le cumul de ces deux approches d'optimisation fait la différence en réduisant de manière significative la consommation de gaz dans les contrats intelligents. Particulièrement dans certains domaines comme la MEV (arbitrage) où l'optimisation est vitale.
Pour illustrer la chose, la signature de la fonction square(uint32)
modifiée ainsi square_low(uint32)
aura pour empreinte bde6cad1
au lieu de d27b3841
.
La valeur inférieure de la nouvelle empreinte obtenue fera ainsi remonter en priorité le traitement de l'appel de cette fonction.
Cette optimisation peut être importante pour les contrats intelligents très complexes, car elle permet de réduire le temps nécessaire pour rechercher et sélectionner la bonne fonction à appeler, ce qui se traduit par des économies de gaz et des performances améliorées sur la blockchain Ethereum.
Le fait que la recherche soit fractionnée au lieu de linéaire, complique un peu les choses, dans le sens où en fonction du nombre de fonctions et du niveau d'optimisation du compilateur, les valeurs seuils sont plus délicates à déterminer pour choisir les nouvelles signatures en fonction de l'ordre désiré.
Lorsque vous envoyez une transaction sur la blockchain Ethereum, vous incluez des données qui spécifient quelle fonction du contrat intelligent vous souhaitez appeler et quels sont les arguments de cette fonction. Or le coût en gaz d'une transaction dépend en partie du nombre d'octets à zéro dans les données de cette transaction.
Comme précisé dans l'Ethereum Yellow Paper (Berlin version, 🇬🇧)
Gtxdatazero
coûte 4 gas pour chaque octet nul en transaction.Gtxdatanonzero
coûte 16 gas pour chaque octet non-nul, soit 4 fois plus cher.
Ainsi, chaque fois qu'un octet à zéro (00
) est utilisé dans msg.data
en lieu et place d'un octet non-nul, il économise 12 gas.
Cette particularité des EVMs a également un impact sur la consommation d'autres opcodes comme Gsset
et Gsreset
.
Pour illustrer la chose, la signature de la fonction square(uint32)
modifiée ainsi square_Y7i(uint32)
aura pour empreinte 00001878
au lieu de d27b3841
.
Les deux octets de poids forts de l'empreinte (0000
) feront non seulement remonter en priorité le traitement de l'appel de cette fonction comme vu plus haut, mais permettra également de consommer moins de gas lors de l'extraction des données (40 au lieu de 64).
En voici d'autres exemples :
Signatures (optimal) | Empreintes (optimal) | Signatures | Empreintes |
---|---|---|---|
deposit_ps2(uint256) |
0000fee6 | deposit(uint256) |
b6b55f25 |
mint_540(uint256) |
00009d1c | mint(uint256) |
a0712d68 |
b_1Y() |
00008e0c | b() |
4df7e3d0 |
De même pouvoir utiliser des empreintes avec trois octets de poids forts à zéro, permet de ne consommer que 28 gas.
Comme par exemple deposit278591A(uint)
et deposit_3VXa0(uint256)
dont les empreintes respectives, sont 00000070
et 0000007e
.
Par contre, étant donné qu'il ne peut y avoir qu'une valeur unique de sélection (empreinte) il ne peut y avoir qu'une seule fonction dans un contrat dont l'empreinte possède quatre octets à zéro, même si plusieurs signatures peuvent aboutir à cette empreinte optimisée 00000000
permettant de ne consommer que 16 gas (exemple avec la signature suivante : execute_44g58pv()
)
Signatures | Empreintes | # de zéros | Gas | Gain (gas) |
---|---|---|---|---|
execute() |
61461954 |
0 | 64 | 0 |
execute_5Hw() |
00af0043 |
1 | 52 | 8 |
execute_mAX() |
0000eb63 |
2 | 40 | 24 |
execute_6d4S() |
000000ae |
3 | 28 | 36 |
execute_44g58pv() |
00000000 |
4 | 16 | 48 |
J'ai réalisé Select0r, un outil écrit en Rust qui vous permettra de renommer vos fonctions afin d'en optimiser les appels. Le programme fournira pour une signature de fonction donnée, une liste de signatures alternatives moins couteuse en gas, permettant un meilleur ordonancement pour le "function dispatcher".
-
L'optimisation des coûts en gas est un aspect essentiel de la conception de contrats intelligents efficaces sur Ethereum.
-
En faisant attention aux détails tels que l'ordre des signatures de fonction, le nombre de zéros en début de hash, l'ordre de traitement des fonctions, et le renommage des fonctions, vous pouvez réduire de manière significative les coûts associés à votre contrat.
-
Attention toutefois, la convivialité et la lisibilité de votre code peut en être réduite.
-
L'optimisation pour l'exécution n'est pas forcément nécessaire pour les fonctions dites d'administration, ou celles trop peu fréquement appelées.
-
Par contre, c'est à prioriser pour les fonctions supposément les plus fréquemment appelées (à déterminer manuellement ou statistiquement lors de tests pratiques).
-
Une optimisation isolée semble représenter peu de chose, surtout comparée au coût global d'une transaction. En revanche, tout un ensemble d'optimisations opérées sur un ensemble de transactions font toute la différence, et cela ne concerne pas que les optimisations sur le "function dispatcher".
En fin de compte, ces optimisations peuvent faire la différence entre un contrat économique et un contrat coûteux en gas.
Crédits : Franck Maussand
Merci à Igor Bournazel pour ses suggestions et la relecture de cet article.
-
Fonction de hachage :
-
Keccak :
-
Recherche dichotomique :
-
Reférences :
- 🇬🇧 Ethereum Yellow Paper
- 🇬🇧 Opcodes for the EVM
- 🇬🇧 EVM Codes - An Ethereum Virtual Machine Opcodes Interactive Reference
- 🇬🇧 Operations with dynamic Gas costs
- 🇬🇧 Contract ABI Specification — Solidity 0.8.22 documentation
- 🇬🇧 Yul — Solidity 0.8.22 documentation
- 🇬🇧 Yul — Complete ERC20 Example
- 🇬🇧 Using the Compiler — Solidity 0.8.22 documentation
- 🇬🇧 The Optimizer — Solidity 0.8.22 documentation
-
Outils :
-
Divers :
- 🇬🇧 Function Dispatching | Huff Language
- 🇬🇧 Solidity’s Cheap Public Face
- 🇬🇧 Web3 Hacking: Paradigm CTF 2022 Writeup
- 🇬🇧 paradigm-ctf-2022/hint-finance at main · paradigmxyz/paradigm-ctf-2022 · GitHub
- 🇬🇧 GitHub - Laugharne/solc_runs_dispatcher
- 🇬🇧 WhatsABI? with Shazow - YouTube
- 🇬🇧 Ethereum Yellow Paper Walkthrough (4/7) - Transaction Execution