Passer au contenu principal

Mise en cache à volonté

couche 2
mise en cache
stockage
Intermédiaire
Ori Pomerantz
15 septembre 2022
25 minutes de lecture

Lors de l'utilisation de rollups, le coût d'un octet dans la transaction est bien plus élevé que le coût d'un emplacement de stockage. Par conséquent, il est logique de mettre en cache autant d'informations que possible en chaîne.

Dans cet article, vous apprendrez à créer et à utiliser un contrat de mise en cache de telle manière que toute valeur de paramètre susceptible d'être utilisée plusieurs fois soit mise en cache et disponible à l'utilisation (après la première fois) avec un nombre d'octets beaucoup plus faible, et comment écrire du code hors chaîne qui utilise ce cache.

Si vous voulez sauter l'article et voir directement le code source, il est ici (opens in a new tab). La pile de développement est Foundry (opens in a new tab).

Conception générale

Par souci de simplicité, nous supposerons que tous les paramètres de transaction sont de type uint256 et longs de 32 octets. Lorsque nous recevons une transaction, nous analysons chaque paramètre comme ceci :

  1. Si le premier octet est 0xFF, prenez les 32 octets suivants comme valeur de paramètre et écrivez-la dans le cache.

  2. Si le premier octet est 0xFE, prenez les 32 octets suivants comme valeur de paramètre, mais ne l'écrivez pas dans le cache.

  3. Pour toute autre valeur, prenez les quatre bits de poids fort comme le nombre d'octets supplémentaires, et les quatre bits de poids faible comme les bits les plus significatifs de la clé du cache. Voici quelques exemples :

    Octets dans les données d'appelClé du cache
    0x0F0x0F
    0x10,0x100x10
    0x12,0xAC0x02AC
    0x2D,0xEA, 0xD60x0DEAD6

Manipulation du cache

Le cache est implémenté dans Cache.sol (opens in a new tab). Revenons dessus ligne par ligne.

1// SPDX-License-Identifier: UNLICENSED
2pragma solidity ^0.8.13;
3
4
5contract Cache {
6
7 bytes1 public constant INTO_CACHE = 0xFF;
8 bytes1 public constant DONT_CACHE = 0xFE;

Ces constantes sont utilisées pour interpréter les cas spéciaux où nous fournissons toutes les informations et souhaitons ou non les écrire dans le cache. L'écriture dans le cache nécessite deux opérations SSTORE (opens in a new tab) dans des emplacements de stockage précédemment inutilisés, au coût de 22 100 gaz chacune, nous rendons donc cela facultatif.

1
2 mapping(uint => uint) public val2key;

Une correspondance (opens in a new tab) entre les valeurs et leurs clés. Ces informations sont nécessaires pour encoder les valeurs avant d'envoyer la transaction.

1 // L'emplacement n a la valeur pour la clé n+1, car nous devons préserver
2 // zéro comme « pas dans le cache ».
3 uint[] public key2val;

Nous pouvons utiliser un tableau pour la correspondance des clés aux valeurs car nous attribuons les clés, et par souci de simplicité, nous le faisons de manière séquentielle.

1 function cacheRead(uint _key) public view returns (uint) {
2 require(_key <= key2val.length, "Lecture d'une entrée de cache non initialisée");
3 return key2val[_key-1];
4 } // cacheRead

Lire une valeur à partir du cache.

1 // Écrire une valeur dans le cache si elle n'y est pas déjà
2 // Uniquement public pour permettre au test de fonctionner
3 function cacheWrite(uint _value) public returns (uint) {
4 // Si la valeur est déjà dans le cache, retourner la clé actuelle
5 if (val2key[_value] != 0) {
6 return val2key[_value];
7 }

Il n'y a aucun intérêt à mettre la même valeur dans le cache plus d'une fois. Si la valeur est déjà présente, il suffit de retourner la clé existante.

1 // Puisque 0xFE est un cas spécial, la plus grande clé que le cache peut
2 // contenir est 0x0D suivie de 15 0xFF. Si la longueur du cache est déjà aussi
3 // grande, on échoue.
4 // 1 2 3 4 5 6 7 8 9 A B C D E F
5 require(key2val.length+1 < 0x0DFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF,
6 "dépassement du cache");

Je ne pense pas que nous aurons jamais un cache aussi grand (environ 1,8*1037 entrées, ce qui nécessiterait environ 1027 To de stockage). Cependant, je suis assez vieux pour me souvenir du fameux « 640 Ko devraient suffire à tout le monde » (opens in a new tab). Ce test est très peu coûteux.

1 // Écrire la valeur en utilisant la clé suivante
2 val2key[_value] = key2val.length+1;

Ajoutez la recherche inversée (de la valeur à la clé).

1 key2val.push(_value);

Ajoutez la recherche directe (de la clé à la valeur). Comme nous attribuons des valeurs de manière séquentielle, nous pouvons simplement l'ajouter après la dernière valeur du tableau.

1 return key2val.length;
2 } // écritureCache

Retourner la nouvelle longueur de key2val, qui est la cellule où la nouvelle valeur est stockée.

1 function _calldataVal(uint startByte, uint length)
2 private pure returns (uint)

Cette fonction lit une valeur à partir des données d'appel, de longueur arbitraire (jusqu'à 32 octets, la taille d'un mot).

1 {
2 uint _retVal;
3
4 require(length < 0x21,
5 "la limite de longueur de _calldataVal est de 32 octets");
6 require(length + startByte <= msg.data.length,
7 "_calldataVal tente de lire au-delà de calldatasize");

Cette fonction est interne, donc si le reste du code est écrit correctement, ces tests ne sont pas nécessaires. Cependant, ils ne coûtent pas cher, alors autant les avoir.

1 assembly {
2 _retVal := calldataload(startByte)
3 }

Ce code est en Yul (opens in a new tab). Il lit une valeur de 32 octets à partir des données d'appel. Cela fonctionne même si les données d'appel s'arrêtent avant startByte+32, car l'espace non initialisé dans l'EVM est considéré comme étant nul.

1 _retVal = _retVal >> (256-length*8);

Nous n'avons pas nécessairement besoin d'une valeur de 32 octets. Cela permet de se débarrasser des octets excédentaires.

1 return _retVal;
2 } // _calldataVal
3
4
5 // Lire un seul paramètre depuis calldata, en commençant à _fromByte
6 function _readParam(uint _fromByte) internal
7 returns (uint _nextByte, uint _parameterValue)
8 {

Lire un seul paramètre à partir des données d'appel. Notez que nous devons retourner non seulement la valeur que nous avons lue, mais aussi l'emplacement de l'octet suivant, car les paramètres peuvent avoir une longueur allant de 1 à 33 octets.

1 // Le premier octet nous indique comment interpréter le reste
2 uint8 _firstByte;
3
4 _firstByte = uint8(_calldataVal(_fromByte, 1));

Solidity essaie de réduire le nombre de bogues en interdisant les conversions de type implicites (opens in a new tab) potentiellement dangereuses. Une conversion descendante, par exemple de 256 bits à 8 bits, doit être explicite.

1
2 // Lire la valeur, mais ne pas l'écrire dans le cache
3 if (_firstByte == uint8(DONT_CACHE))
4 return(_fromByte+33, _calldataVal(_fromByte+1, 32));
5
6 // Lire la valeur et l'écrire dans le cache
7 if (_firstByte == uint8(INTO_CACHE)) {
8 uint _param = _calldataVal(_fromByte+1, 32);
9 cacheWrite(_param);
10 return(_fromByte+33, _param);
11 }
12
13 // Si nous arrivons ici, cela signifie que nous devons lire depuis le cache
14
15 // Nombre d'octets supplémentaires à lire
16 uint8 _extraBytes = _firstByte / 16;
Afficher tout

Prenez le quartet (opens in a new tab) inférieur et combinez-le avec les autres octets pour lire la valeur du cache.

1 uint _key = (uint256(_firstByte & 0x0F) << (8*_extraBytes)) +
2 _calldataVal(_fromByte+1, _extraBytes);
3
4 return (_fromByte+_extraBytes+1, cacheRead(_key));
5
6 } // _readParam
7
8
9 // Lire n paramètres (les fonctions savent combien de paramètres elles attendent)
10 function _readParams(uint _paramNum) internal returns (uint[] memory) {
Afficher tout

Nous pourrions obtenir le nombre de paramètres à partir des données d'appel elles-mêmes, mais les fonctions qui nous appellent savent combien de paramètres elles attendent. Il est plus simple de les laisser nous le dire.

1 // Les paramètres que nous lisons
2 uint[] memory params = new uint[](_paramNum);
3
4 // Les paramètres commencent à l'octet 4, avant cela se trouve la signature de la fonction
5 uint _atByte = 4;
6
7 for(uint i=0; i<_paramNum; i++) {
8 (_atByte, params[i]) = _readParam(_atByte);
9 }
Afficher tout

Lisez les paramètres jusqu'à ce que vous ayez le nombre requis. Si nous dépassons la fin des données d'appel, _readParams annulera l'appel.

1
2 return(params);
3 } // readParams
4
5 // Pour tester _readParams, on teste la lecture de quatre paramètres
6 function fourParam() public
7 returns (uint256,uint256,uint256,uint256)
8 {
9 uint[] memory params;
10 params = _readParams(4);
11 return (params[0], params[1], params[2], params[3]);
12 } // fourParam
Afficher tout

Un grand avantage de Foundry est qu'il permet d'écrire des tests en Solidity (voir Tester le cache ci-dessous). Cela facilite grandement les tests unitaires. Il s'agit d'une fonction qui lit quatre paramètres et les retourne pour que le test puisse vérifier s'ils étaient corrects.

1 // Obtenir une valeur, retourner les octets qui l'encoderont (en utilisant le cache si possible)
2 function encodeVal(uint _val) public view returns(bytes memory) {

encodeVal est une fonction que le code hors chaîne appelle pour aider à créer des données d'appel qui utilisent le cache. Elle reçoit une seule valeur et retourne les octets qui l'encodent. Cette fonction est une vue, elle ne nécessite donc pas de transaction et, lorsqu'elle est appelée de l'extérieur, ne coûte pas de gaz.

1 uint _key = val2key[_val];
2
3 // La valeur n'est pas encore dans le cache, on l'ajoute
4 if (_key == 0)
5 return bytes.concat(INTO_CACHE, bytes32(_val));

Dans l'EVM, tout le stockage non initialisé est supposé être nul. Donc, si nous cherchons la clé d'une valeur qui n'est pas là, nous obtenons un zéro. Dans ce cas, les octets qui l'encodent sont INTO_CACHE (afin qu'elle soit mise en cache la prochaine fois), suivis de la valeur réelle.

1 // Si la clé est <0x10, on la retourne comme un seul octet
2 if (_key < 0x10)
3 return bytes.concat(bytes1(uint8(_key)));

Les octets uniques sont les plus simples. Nous utilisons simplement bytes.concat (opens in a new tab) pour transformer un type bytes<n> en un tableau d'octets de n'importe quelle longueur. Malgré son nom, cela fonctionne bien lorsqu'on ne lui fournit qu'un seul argument.

1 // Valeur sur deux octets, encodée comme 0x1vvv
2 if (_key < 0x1000)
3 return bytes.concat(bytes2(uint16(_key) | 0x1000));

Lorsque nous avons une clé inférieure à 163, nous pouvons l'exprimer en deux octets. Nous convertissons d'abord _key, qui est une valeur de 256 bits, en une valeur de 16 bits et utilisons un OU logique pour ajouter le nombre d'octets supplémentaires au premier octet. Ensuite, nous la convertissons en une valeur bytes2, qui peut être convertie en bytes.

1 // Il existe probablement une manière astucieuse de faire les lignes suivantes en boucle,
2 // mais c'est une fonction de vue, donc j'optimise le temps du programmeur et
3 // la simplicité.
4
5 if (_key < 16*256**2)
6 return bytes.concat(bytes3(uint24(_key) | (0x2 * 16 * 256**2)));
7 if (_key < 16*256**3)
8 return bytes.concat(bytes4(uint32(_key) | (0x3 * 16 * 256**3)));
9 .
10 .
11 .
12 if (_key < 16*256**14)
13 return bytes.concat(bytes15(uint120(_key) | (0xE * 16 * 256**14)));
14 if (_key < 16*256**15)
15 return bytes.concat(bytes16(uint128(_key) | (0xF * 16 * 256**15)));
Afficher tout

Les autres valeurs (3 octets, 4 octets, etc.) sont traitées de la même manière, mais avec des tailles de champ différentes.

1 // Si nous arrivons ici, quelque chose ne va pas.
2 revert("Erreur dans encodeVal, ne devrait pas se produire");

Si nous arrivons ici, cela signifie que nous avons obtenu une clé qui n'est pas inférieure à 16*25615. Mais cacheWrite limite les clés, donc nous ne pouvons même pas atteindre 14*25616 (ce qui donnerait un premier octet de 0xFE, et ressemblerait donc à DONT_CACHE). Mais cela ne coûte pas cher d'ajouter un test au cas où un futur programmeur introduirait un bogue.

1 } // encodeVal
2
3} // Cache

Tester le cache

L'un des avantages de Foundry est qu'il permet d'écrire des tests en Solidity (opens in a new tab), ce qui facilite l'écriture de tests unitaires. Les tests pour la classe Cache sont ici (opens in a new tab). Le code de test étant répétitif, comme c'est souvent le cas, cet article n'explique que les parties intéressantes.

1// SPDX-License-Identifier: UNLICENSED
2pragma solidity ^0.8.13;
3
4import "forge-std/Test.sol";
5
6
7// Il faut lancer `forge test -vv` pour la console.
8import "forge-std/console.sol";

Ceci est juste du code standard nécessaire pour utiliser le paquet de test et console.log.

1import "src/Cache.sol";

Nous devons connaître le contrat que nous testons.

1contract CacheTest is Test {
2 Cache cache;
3
4 function setUp() public {
5 cache = new Cache();
6 }

La fonction setUp est appelée avant chaque test. Dans ce cas, nous créons simplement un nouveau cache, afin que nos tests ne s'affectent pas les uns les autres.

1 function testCaching() public {

Les tests sont des fonctions dont le nom commence par test. Cette fonction vérifie la fonctionnalité de base du cache, en écrivant des valeurs et en les relisant.

1 for(uint i=1; i<5000; i++) {
2 cache.cacheWrite(i*i);
3 }
4
5 for(uint i=1; i<5000; i++) {
6 assertEq(cache.cacheRead(i), i*i);

C'est ainsi que vous effectuez les tests réels, en utilisant les fonctions assert... (opens in a new tab). Dans ce cas, nous vérifions que la valeur que nous avons écrite est bien celle que nous avons lue. Nous pouvons ignorer le résultat de cache.cacheWrite car nous savons que les clés de cache sont attribuées de manière linéaire.

1 }
2 } // testCaching
3
4
5 // Mettre en cache la même valeur plusieurs fois, s'assurer que la clé reste
6 // la même
7 function testRepeatCaching() public {
8 for(uint i=1; i<100; i++) {
9 uint _key1 = cache.cacheWrite(i);
10 uint _key2 = cache.cacheWrite(i);
11 assertEq(_key1, _key2);
12 }
Afficher tout

D'abord, nous écrivons chaque valeur deux fois dans le cache et nous nous assurons que les clés sont les mêmes (ce qui signifie que la deuxième écriture n'a pas vraiment eu lieu).

1 for(uint i=1; i<100; i+=3) {
2 uint _key = cache.cacheWrite(i);
3 assertEq(_key, i);
4 }
5 } // testRepeatCaching

En théorie, il pourrait y avoir un bogue qui n'affecte pas les écritures consécutives dans le cache. Ici, nous effectuons donc des écritures non consécutives et nous voyons que les valeurs ne sont toujours pas réécrites.

1 // Lire un uint à partir d'un tampon mémoire (pour s'assurer de récupérer les paramètres
2 // que nous avons envoyés)
3 function toUint256(bytes memory _bytes, uint256 _start) internal pure
4 returns (uint256)

Lire un mot de 256 bits à partir d'un tampon bytes memory. Cette fonction utilitaire nous permet de vérifier que nous recevons les résultats corrects lorsque nous exécutons un appel de fonction qui utilise le cache.

1 {
2 require(_bytes.length >= _start + 32, "toUint256_outOfBounds");
3 uint256 tempUint;
4
5 assembly {
6 tempUint := mload(add(add(_bytes, 0x20), _start))
7 }

Yul ne prend pas en charge les structures de données au-delà de uint256, donc lorsque vous faites référence à une structure de données plus sophistiquée, telle que le tampon mémoire _bytes, vous obtenez l'adresse de cette structure. Solidity stocke les valeurs bytes memory sous la forme d'un mot de 32 octets qui contient la longueur, suivi des octets réels. Pour obtenir l'octet numéro _start, nous devons donc calculer _bytes+32+_start.

1
2 return tempUint;
3 } // toUint256
4
5 // Signature de la fonction pour fourParams(), grâce à
6 // https://www.4byte.directory/signatures/?bytes4_signature=0x3edc1e6d
7 bytes4 constant FOUR_PARAMS = 0x3edc1e6d;
8
9 // Juste quelques valeurs constantes pour voir si nous obtenons les bonnes valeurs en retour
10 uint256 constant VAL_A = 0xDEAD60A7;
11 uint256 constant VAL_B = 0xBEEF;
12 uint256 constant VAL_C = 0x600D;
13 uint256 constant VAL_D = 0x600D60A7;
Afficher tout

Quelques constantes dont nous avons besoin pour les tests.

1 function testReadParam() public {

Appelez fourParams(), une fonction qui utilise readParams, pour tester que nous pouvons lire les paramètres correctement.

1 address _cacheAddr = address(cache);
2 bool _success;
3 bytes memory _callInput;
4 bytes memory _callOutput;

Nous ne pouvons pas utiliser le mécanisme ABI normal pour appeler une fonction en utilisant le cache, nous devons donc utiliser le mécanisme de bas niveau <address>.call() (opens in a new tab). Ce mécanisme prend un bytes memory en entrée et le retourne (ainsi qu'une valeur booléenne) en sortie.

1 // Premier appel, le cache est vide
2 _callInput = bytes.concat(
3 FOUR_PARAMS,

Il est utile que le même contrat prenne en charge à la fois les fonctions mises en cache (pour les appels directement depuis les transactions) et les fonctions non mises en cache (pour les appels depuis d'autres contrats intelligents). Pour ce faire, nous devons continuer à nous appuyer sur le mécanisme de Solidity pour appeler la bonne fonction, au lieu de tout mettre dans une fonction de repli (fallback) (opens in a new tab). Cela rend la composabilité beaucoup plus facile. Un seul octet suffirait pour identifier la fonction dans la plupart des cas, nous gaspillons donc trois octets (16*3=48 gaz). Cependant, au moment où j'écris ces lignes, ces 48 gaz coûtent 0,07 centime, ce qui est un coût raisonnable pour un code plus simple et moins sujet aux bogues.

1 // Première valeur, on l'ajoute au cache
2 cache.INTO_CACHE(),
3 bytes32(VAL_A),

La première valeur : un indicateur signalant qu'il s'agit d'une valeur complète qui doit être écrite dans le cache, suivi des 32 octets de la valeur. Les trois autres valeurs sont similaires, sauf que VAL_B n'est pas écrit dans le cache et que VAL_C est à la fois le troisième et le quatrième paramètre.

1 .
2 .
3 .
4 );
5 (_success, _callOutput) = _cacheAddr.call(_callInput);

C'est ici que nous appelons réellement le contrat Cache.

1 assertEq(_success, true);

Nous nous attendons à ce que l'appel réussisse.

1 assertEq(cache.cacheRead(1), VAL_A);
2 assertEq(cache.cacheRead(2), VAL_C);

Nous commençons avec un cache vide, puis nous ajoutons VAL_A suivi de VAL_C. Nous nous attendons à ce que le premier ait la clé 1 et le second la clé 2.

1 assertEq(toUint256(_callOutput,0), VAL_A);
2 assertEq(toUint256(_callOutput,32), VAL_B);
3 assertEq(toUint256(_callOutput,64), VAL_C);
4 assertEq(toUint256(_callOutput,96), VAL_C);

La sortie correspond aux quatre paramètres. Ici, nous vérifions qu'elle est correcte.

1 // Deuxième appel, nous pouvons utiliser le cache
2 _callInput = bytes.concat(
3 FOUR_PARAMS,
4
5 // Première valeur dans le cache
6 bytes1(0x01),

Les clés de cache inférieures à 16 ne représentent qu'un seul octet.

1 // Deuxième valeur, ne pas l'ajouter au cache
2 cache.DONT_CACHE(),
3 bytes32(VAL_B),
4
5 // Troisième et quatrième valeurs, même valeur
6 bytes1(0x02),
7 bytes1(0x02)
8 );
9 .
10 .
11 .
Afficher tout

Les tests après l'appel sont identiques à ceux après le premier appel.

1 function testEncodeVal() public {

Cette fonction est similaire à testReadParam, sauf qu'au lieu d'écrire les paramètres explicitement, nous utilisons encodeVal().

1 .
2 .
3 .
4 _callInput = bytes.concat(
5 FOUR_PARAMS,
6 cache.encodeVal(VAL_A),
7 cache.encodeVal(VAL_B),
8 cache.encodeVal(VAL_C),
9 cache.encodeVal(VAL_D)
10 );
11 .
12 .
13 .
14 assertEq(_callInput.length, 4+1*4);
15 } // testEncodeVal
Afficher tout

Le seul test supplémentaire dans testEncodeVal() consiste à vérifier que la longueur de _callInput est correcte. Pour le premier appel, elle est de 4+334. Pour le second, où chaque valeur est déjà dans le cache, elle est de 4+14.

1 // Tester encodeVal lorsque la clé fait plus d'un seul octet
2 // Maximum trois octets, car remplir le cache jusqu'à quatre octets prend
3 // trop de temps.
4 function testEncodeValBig() public {
5 // Mettre un certain nombre de valeurs dans le cache.
6 // Pour rester simple, utiliser la clé n pour la valeur n.
7 for(uint i=1; i<0x1FFF; i++) {
8 cache.cacheWrite(i);
9 }
Afficher tout

La fonction testEncodeVal ci-dessus n'écrit que quatre valeurs dans le cache, donc la partie de la fonction qui traite les valeurs multi-octets (opens in a new tab) n'est pas vérifiée. Mais ce code est compliqué et sujet aux erreurs.

La première partie de cette fonction est une boucle qui écrit toutes les valeurs de 1 à 0x1FFF dans le cache, dans l'ordre, afin que nous puissions encoder ces valeurs et savoir où elles vont.

1 .
2 .
3 .
4
5 _callInput = bytes.concat(
6 FOUR_PARAMS,
7 cache.encodeVal(0x000F), // Un octet 0x0F
8 cache.encodeVal(0x0010), // Deux octets 0x1010
9 cache.encodeVal(0x0100), // Deux octets 0x1100
10 cache.encodeVal(0x1000) // Trois octets 0x201000
11 );
Afficher tout

Testez les valeurs d'un, deux et trois octets. Nous ne testons pas au-delà car cela prendrait trop de temps pour écrire suffisamment d'entrées dans la pile (au moins 0x10000000, soit environ un quart de milliard).

1 .
2 .
3 .
4 .
5 } // testEncodeValBig
6
7
8 // Tester qu'avec un tampon excessivement petit, nous obtenons une annulation
9 function testShortCalldata() public {
Afficher tout

Testez ce qui se passe dans le cas anormal où il n'y a pas assez de paramètres.

1 .
2 .
3 .
4 (_success, _callOutput) = _cacheAddr.call(_callInput);
5 assertEq(_success, false);
6 } // testShortCalldata

Puisqu'il y a annulation, le résultat que nous devrions obtenir est false.

1 // Appeler avec des clés de cache qui ne sont pas là
2 function testNoCacheKey() public {
3 .
4 .
5 .
6 _callInput = bytes.concat(
7 FOUR_PARAMS,
8
9 // Première valeur, l'ajouter au cache
10 cache.INTO_CACHE(),
11 bytes32(VAL_A),
12
13 // Deuxième valeur
14 bytes1(0x0F),
15 bytes2(0x1234),
16 bytes11(0xA10102030405060708090A)
17 );
Afficher tout

Cette fonction reçoit quatre paramètres parfaitement légitimes, sauf que le cache est vide, donc il n'y a aucune valeur à lire.

1 .
2 .
3 .
4 // Tester qu'avec un tampon excessivement long tout fonctionne
5 function testLongCalldata() public {
6 address _cacheAddr = address(cache);
7 bool _success;
8 bytes memory _callInput;
9 bytes memory _callOutput;
10
11 // Premier appel, le cache est vide
12 _callInput = bytes.concat(
13 FOUR_PARAMS,
14
15 // Première valeur, l'ajouter au cache
16 cache.INTO_CACHE(), bytes32(VAL_A),
17
18 // Deuxième valeur, l'ajouter au cache
19 cache.INTO_CACHE(), bytes32(VAL_B),
20
21 // Troisième valeur, l'ajouter au cache
22 cache.INTO_CACHE(), bytes32(VAL_C),
23
24 // Quatrième valeur, l'ajouter au cache
25 cache.INTO_CACHE(), bytes32(VAL_D),
26
27 // Et une autre valeur pour la « bonne chance »
28 bytes4(0x31112233)
29 );
Afficher tout

Cette fonction envoie cinq valeurs. Nous savons que la cinquième valeur est ignorée car ce n'est pas une entrée de cache valide, ce qui aurait provoqué une annulation si elle n'avait pas été incluse.

1 (_success, _callOutput) = _cacheAddr.call(_callInput);
2 assertEq(_success, true);
3 .
4 .
5 .
6 } // testLongCalldata
7
8} // CacheTest
9
Afficher tout

Un exemple d'application

Écrire des tests en Solidity est très bien, mais au final, une dapp doit être capable de traiter des requêtes provenant de l'extérieur de la chaîne pour être utile. Cet article montre comment utiliser la mise en cache dans une dapp avec WORM, qui signifie « Write Once, Read Many » (Écrire une fois, lire plusieurs fois). Si une clé n'est pas encore écrite, vous pouvez y écrire une valeur. Si la clé est déjà écrite, vous obtenez une annulation.

Le contrat

Voici le contrat (opens in a new tab). Il répète en grande partie ce que nous avons déjà fait avec Cache et CacheTest, nous ne couvrirons donc que les parties intéressantes.

1import "./Cache.sol";
2
3contract WORM is Cache {

La manière la plus simple d'utiliser Cache est de l'hériter dans notre propre contrat.

1 function writeEntryCached() external {
2 uint[] memory params = _readParams(2);
3 writeEntry(params[0], params[1]);
4 } // writeEntryCached

Cette fonction est similaire à fourParam dans CacheTest ci-dessus. Comme nous ne suivons pas les spécifications de l'ABI, il est préférable de ne déclarer aucun paramètre dans la fonction.

1 // Pour faciliter les appels
2 // Signature de la fonction pour writeEntryCached(), grâce à
3 // https://www.4byte.directory/signatures/?bytes4_signature=0xe4e4f2d3
4 bytes4 constant public WRITE_ENTRY_CACHED = 0xe4e4f2d3;

Le code externe qui appelle writeEntryCached devra construire manuellement les données d'appel, au lieu d'utiliser worm.writeEntryCached, car nous ne suivons pas les spécifications de l'ABI. Avoir cette valeur constante facilite simplement son écriture.

Notez que même si nous définissons WRITE_ENTRY_CACHED comme une variable d'état, pour la lire de l'extérieur, il est nécessaire d'utiliser sa fonction d'accès, worm.WRITE_ENTRY_CACHED().

1 function readEntry(uint key) public view
2 returns (uint _value, address _writtenBy, uint _writtenAtBlock)

La fonction de lecture est une vue, elle ne nécessite donc pas de transaction et ne coûte pas de gaz. Par conséquent, il n'y a aucun avantage à utiliser le cache pour le paramètre. Avec les fonctions de vue, il est préférable d'utiliser le mécanisme standard qui est plus simple.

Le code de test

Voici le code de test du contrat (opens in a new tab). Encore une fois, ne regardons que ce qui est intéressant.

1 function testWReadWrite() public {
2 worm.writeEntry(0xDEAD, 0x60A7);
3
4 vm.expectRevert(bytes("entry already written"));
5 worm.writeEntry(0xDEAD, 0xBEEF);

Ceci (vm.expectRevert) (opens in a new tab) est la façon dont nous spécifions dans un test Foundry que l'appel suivant doit échouer, ainsi que la raison de l'échec. Cela s'applique lorsque nous utilisons la syntaxe <contract>.<function name>() plutôt que de construire les données d'appel et d'appeler le contrat en utilisant l'interface de bas niveau (<contract>.call(), etc.).

1 function testReadWriteCached() public {
2 uint cacheGoat = worm.cacheWrite(0x60A7);

Ici, nous utilisons le fait que cacheWrite retourne la clé du cache. Ce n'est pas quelque chose que nous nous attendrions à utiliser en production, car cacheWrite modifie l'état, et ne peut donc être appelé que lors d'une transaction. Les transactions n'ont pas de valeurs de retour ; si elles ont des résultats, ces derniers sont censés être émis sous forme d'événements. La valeur de retour de cacheWrite n'est donc accessible qu'à partir du code en chaîne, et le code en chaîne n'a pas besoin de mise en cache des paramètres.

1 (_success,) = address(worm).call(_callInput);

C'est ainsi que nous indiquons à Solidity que, bien que <contract address>.call() ait deux valeurs de retour, nous ne nous intéressons qu'à la première.

1 (_success,) = address(worm).call(_callInput);
2 assertEq(_success, false);

Puisque nous utilisons la fonction de bas niveau <address>.call(), nous ne pouvons pas utiliser vm.expectRevert() et devons regarder la valeur de succès booléenne que nous obtenons de l'appel.

1 event EntryWritten(uint indexed key, uint indexed value);
2
3 .
4 .
5 .
6
7 _callInput = bytes.concat(
8 worm.WRITE_ENTRY_CACHED(), worm.encodeVal(a), worm.encodeVal(b));
9 vm.expectEmit(true, true, false, false);
10 emit EntryWritten(a, b);
11 (_success,) = address(worm).call(_callInput);
Afficher tout

C'est la manière de vérifier que le code émet un événement correctement (opens in a new tab) dans Foundry.

Le client

Une chose que vous n'obtenez pas avec les tests Solidity, c'est du code JavaScript que vous pouvez copier et coller dans votre propre application. Pour écrire ce code, j'ai déployé WORM sur Optimism Goerli (opens in a new tab), le nouveau réseau de test d'Optimism (opens in a new tab). Il se trouve à l'adresse 0xd34335b1d818cee54e3323d3246bd31d94e6a78a (opens in a new tab).

Vous pouvez voir le code JavaScript pour le client ici (opens in a new tab). Pour l'utiliser :

  1. Clonez le dépôt git :

    1git clone https://github.com/qbzzt/20220915-all-you-can-cache.git
  2. Installez les paquets nécessaires :

    1cd javascript
    2yarn
  3. Copiez le fichier de configuration :

    1cp .env.example .env
  4. Modifiez .env selon votre configuration :

    ParamètreValeur
    MNEMONICLa mnémonique d'un compte qui dispose de suffisamment d'ETH pour payer une transaction. Vous pouvez obtenir de l'ETH gratuit pour le réseau Optimism Goerli ici (opens in a new tab).
    OPTIMISM_GOERLI_URLURL vers Optimism Goerli. Le point de terminaison public, https://goerli.optimism.io, a un débit limité mais suffisant pour ce dont nous avons besoin ici.
  5. Exécutez index.js.

    1node index.js

    Cet exemple d'application écrit d'abord une entrée dans WORM, en affichant les données d'appel et un lien vers la transaction sur Etherscan. Ensuite, il relit cette entrée et affiche la clé qu'il utilise et les valeurs de l'entrée (valeur, numéro de bloc et auteur).

La plupart du client est du JavaScript de Dapp normal. Donc, encore une fois, nous ne passerons en revue que les parties intéressantes.

1.
2.
3.
4const main = async () => {
5 const func = await worm.WRITE_ENTRY_CACHED()
6
7 // Il faut une nouvelle clé à chaque fois
8 const key = await worm.encodeVal(Number(new Date()))

Un emplacement donné ne peut être écrit qu'une seule fois, nous utilisons donc l'horodatage pour nous assurer de ne pas réutiliser les emplacements.

1const val = await worm.encodeVal("0x600D")
2
3// Écrire une entrée
4const calldata = func + key.slice(2) + val.slice(2)

Ethers s'attend à ce que les données d'appel soient une chaîne hexadécimale, 0x suivi d'un nombre pair de chiffres hexadécimaux. Comme key et val commencent tous les deux par 0x, nous devons supprimer ces en-têtes.

1const tx = await worm.populateTransaction.writeEntryCached()
2tx.data = calldata
3
4sentTx = await wallet.sendTransaction(tx)

Comme pour le code de test Solidity, nous ne pouvons pas appeler une fonction mise en cache normalement. À la place, nous devons utiliser un mécanisme de plus bas niveau.

1 .
2 .
3 .
4 // Lire l'entrée qui vient d'être écrite
5 const realKey = '0x' + key.slice(4) // supprimer l'indicateur FF
6 const entryRead = await worm.readEntry(realKey)
7 .
8 .
9 .
Afficher tout

Pour lire les entrées, nous pouvons utiliser le mécanisme normal. Il n'est pas nécessaire d'utiliser la mise en cache des paramètres avec les fonctions view.

Conclusion

Le code de cet article est une preuve de concept, le but est de rendre l'idée facile à comprendre. Pour un système prêt pour la production, vous pourriez vouloir implémenter des fonctionnalités supplémentaires :

  • Gérer les valeurs qui ne sont pas de type uint256. Par exemple, des chaînes de caractères.

  • Au lieu d'un cache global, vous pourriez avoir une correspondance entre les utilisateurs et les caches. Différents utilisateurs utilisent des valeurs différentes.

  • Les valeurs utilisées pour les adresses sont distinctes de celles utilisées à d'autres fins. Il pourrait être judicieux d'avoir un cache séparé uniquement pour les adresses.

  • Actuellement, les clés de cache suivent un algorithme « premier arrivé, clé la plus petite ». Les seize premières valeurs peuvent être envoyées en un seul octet. Les 4080 valeurs suivantes peuvent être envoyées sur deux octets. Le million de valeurs suivant est de trois octets, etc. Un système de production devrait conserver des compteurs d'utilisation sur les entrées du cache et les réorganiser de manière à ce que les seize valeurs les plus courantes soient sur un octet, les 4 080 valeurs les plus courantes suivantes sur deux octets, etc.

    Cependant, c'est une opération potentiellement dangereuse. Imaginez la séquence d'événements suivante :

    1. Noam Naïf appelle encodeVal pour encoder l'adresse à laquelle il veut envoyer des jetons. Cette adresse est l'une des premières utilisées sur l'application, donc la valeur encodée est 0x06. C'est une fonction view, pas une transaction, donc c'est entre Noam et le nœud qu'il utilise, et personne d'autre n'est au courant.

    2. Pierre Propriétaire exécute l'opération de réorganisation du cache. Très peu de gens utilisent réellement cette adresse, elle est donc maintenant encodée en 0x201122. Une valeur différente, 1018, se voit attribuer 0x06.

    3. Noam Naïf envoie ses jetons à 0x06. Ils vont à l'adresse 0x0000000000000000000000000de0b6b3a7640000, et comme personne ne connaît la clé privée de cette adresse, ils sont simplement bloqués là. Noam n'est pas content.

    Il existe des moyens de résoudre ce problème, ainsi que le problème connexe des transactions qui se trouvent dans le mempool pendant la réorganisation du cache, mais vous devez en être conscient.

J'ai fait ici une démonstration de la mise en cache avec Optimism, parce que je suis un employé d'Optimism et que c'est le rollup que je connais le mieux. Mais cela devrait fonctionner avec n'importe quel rollup qui facture un coût minimal pour le traitement interne, de sorte qu'en comparaison, l'écriture des données de transaction sur la L1 soit la principale dépense.

Voir ici pour plus de mon travail (opens in a new tab).

Dernière mise à jour de la page : 25 février 2026

Ce tutoriel vous a été utile ?