Passer au contenu principal

Tout ce qui se cache

Couche 2mise en cachestockage
Intermédiaire
Ori Pomerantz
15 septembre 2022
24 minutes de lecture minute read

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 sur la blockchain.

Dans cet article, vous apprendrez comment créer et utiliser un contrat de mise en cache afin 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 plus réduit d'octets, enfin vous apprendrez comment écrire du code qui utilise cette technique de mise en cache.

Si vous souhaitez ignorer l'article et simplement voir le code source, vous le trouverez ici(opens in a new tab). Le logiciel de développement utilisé est Foundry(opens in a new tab).

Conception générale

Pour simplifier, nous supposerons que tous les paramètres de transaction sont de type uint256, d'une longueur de 32 octets. Lorsque nous recevons une transaction, nous analyserons chaque paramètre de la manière suivante :

  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 premiers bits comme le nombre d'octets supplémentaires, et les quatre bits de fin comme les bits les plus significatifs de la clé de la mise en cache. Voici quelques exemples :

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

Manipulation du cache

La mise en cache est implémentée 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;
Copier

Ces constantes sont utilisées pour gérer les cas spéciaux où nous fournissons toutes les informations et souhaitons ou non les écrire dans la mise en cache. Écrire dans le cache nécessite deux opérations SSTORE(opens in a new tab) dans des emplacements de stockage précédemment libres, au coût de 22100 gaz chacune, cela est donc facultatif.

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

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 // Location n has the value for key n+1, because we need to preserve
2 // zero as "not in the cache".
3 uint[] public key2val;
Copier

Nous pouvons utiliser un tableau pour le mappage des clés aux valeurs car nous attribuons les clés, et pour simplifier, nous le faisons de manière séquentielle.

1 function cacheRead(uint _key) public view returns (uint) {
2 require(_key <= key2val.length, "Reading uninitialize cache entry");
3 return key2val[_key-1];
4 } // cacheRead
Copier

Lire une valeur dans le cache.

1 // Write a value to the cache if it's not there already
2 // Only public to enable the test to work
3 function cacheWrite(uint _value) public returns (uint) {
4 // If the value is already in the cache, return the current key
5 if (val2key[_value] != 0) {
6 return val2key[_value];
7 }
Copier

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, renvoyez simplement la clé existante.

1 // Since 0xFE is a special case, the largest key the cache can
2 // hold is 0x0D followed by 15 0xFF's. If the cache length is already that
3 // large, fail.
4 // 1 2 3 4 5 6 7 8 9 A B C D E F
5 require(key2val.length+1 < 0x0DFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF,
6 "cache overflow");
Copier

Je ne pense pas que nous aurons un cache aussi grand (environ 1.8*1037 valeurs, ce qui nécessiterait environ 1027 To de stockage). Cependant, je suis assez vieux pour me rappeler que "640 ko sera toujours suffisant"(opens in a new tab). Ce test est très peu coûteux.

1 // Write the value using the next key
2 val2key[_value] = key2val.length+1;
Copier

Ajoutez la recherche inversée (trouver la clé en fonction de la valeur).

1 key2val.push(_value);
Copier

Ajoutez la recherche directe (trouver la valeur en fonction de la clé). 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 } // cacheWrite
Copier

Renvoyez 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)
Copier

Cette fonction lit une valeur à partir du calldata d'une longueur arbitraire (jusqu'à 32 octets, la taille d'un mot).

1 {
2 uint _retVal;
3
4 require(length < 0x21,
5 "_calldataVal length limit is 32 bytes");
6 require(length + startByte <= msg.data.length,
7 "_calldataVal trying to read beyond calldatasize");
Copier

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 grand-chose, donc autant les inclure.

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

Ce code est en Yul(opens in a new tab). Il lit une valeur de 32 octets à partir du calldata. Cela fonctionne même si le calldata s'arrête avant startByte+32 car l'espace non initialisé dans l'EVM est considéré comme nul.

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

Nous n'avons pas nécessairement besoin d'une valeur de 32 octets. Cela élimine les octets en trop.

1 return _retVal;
2 } // _calldataVal
3
4
5 // Read a single parameter from the calldata, starting at _fromByte
6 function _readParam(uint _fromByte) internal
7 returns (uint _nextByte, uint _parameterValue)
8 {
Copier

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

1 // The first byte tells us how to interpret the rest
2 uint8 _firstByte;
3
4 _firstByte = uint8(_calldataVal(_fromByte, 1));
Copier

Solidity s'efforce de réduire le nombre de bugs en interdisant les conversions implicites(opens in a new tab) car potentiellement dangereuses. Une réduction, par exemple de 256 bits à 8 bits, doit être explicitement indiquée.

1
2 // Read the value, but do not write it to the cache
3 if (_firstByte == uint8(DONT_CACHE))
4 return(_fromByte+33, _calldataVal(_fromByte+1, 32));
5
6 // Read the value, and write it to the 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 // If we got here it means that we need to read from the cache
14
15 // Number of extra bytes to read
16 uint8 _extraBytes = _firstByte / 16;
Afficher tout
Copier

Prendre le nibble(opens in a new tab) inférieur et le combiner avec les autres octets pour lire la valeur depuis le 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 // Read n parameters (functions know how many parameters they expect)
10 function _readParams(uint _paramNum) internal returns (uint[] memory) {
Afficher tout
Copier

Nous pourrions obtenir le nombre de paramètres que nous avons à partir du calldata en lui-même, mais les fonctions qui nous appellent connaissent le nombre de paramètres qu'elles attendent. Il est plus facile de les laisser faire.

1 // The parameters we read
2 uint[] memory params = new uint[](_paramNum);
3
4 // Parameters start at byte 4, before that it's the function signature
5 uint _atByte = 4;
6
7 for(uint i=0; i<_paramNum; i++) {
8 (_atByte, params[i]) = _readParam(_atByte);
9 }
Afficher tout
Copier

Lisez les paramètres jusqu'à obtenir le nombre requis. Si nous dépassons la fin du calldata, _readParams annulera l'appel.

1
2 return(params);
3 } // readParams
4
5 // For testing _readParams, test reading four parameters
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
Copier

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

1 // Get a value, return bytes that will encode it (using the cache if possible)
2 function encodeVal(uint _val) public view returns(bytes memory) {
Copier

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

1 uint _key = val2key[_val];
2
3 // The value isn't in the cache yet, add it
4 if (_key == 0)
5 return bytes.concat(INTO_CACHE, bytes32(_val));
Copier

Dans l'EVM, toute zone de stockage non initialisée est considérée comme valant zéro. Ainsi, si nous recherchons la clé d'une valeur qui n'existe pas, nous obtenons zéro. Dans ce cas, les octets qui l'encodent sont dans INTO_CACHE (afin qu'elle soit mise en cache la prochaine fois), suivis de la valeur réelle.

1 // If the key is <0x10, return it as a single byte
2 if (_key < 0x10)
3 return bytes.concat(bytes1(uint8(_key)));
Copier

Les simples octets sont les plus simples. Nous utilisons simplement bytes.concat(opens in a new tab) pour convertir un type bytes en un tableau d'octets de n'importe quelle longueur. Malgré le nom, cela fonctionne quand même lorsqu'un seul argument est fourni.

1 // Two byte value, encoded as 0x1vvv
2 if (_key < 0x1000)
3 return bytes.concat(bytes2(uint16(_key) | 0x1000));
Copier

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

1 // There is probably a clever way to do the following lines as a loop,
2 // but it's a view function so I'm optimizing for programmer time and
3 // simplicity.
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
Copier

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

1 // If we get here, something is wrong.
2 revert("Error in encodeVal, should not happen");
Copier

Si nous en arrivons là, cela signifie que nous avons obtenu une valeur 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 aurait un premier octet de 0xFE, donc cela ressemblerait à DONT_CACHE). Mais cela ne nous coûte pas beaucoup d'ajouter un test au cas où un futur programmeur introduirait un bug.

1 } // encodeVal
2
3} // Cache
Copier

Tester le cache

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

1// SPDX-License-Identifier: UNLICENSED
2pragma solidity ^0.8.13;
3
4import "forge-std/Test.sol";
5
6
7// Need to run `forge test -vv` for the console.
8import "forge-std/console.sol";
Copier

Il s'agit simplement d'un passe-partout nécessaire pour utiliser le package de test et console.log.

1import "src/Cache.sol";
Copier

Nous avons besoin de connaître le contrat que nous testons.

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

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 {
Copier

Les tests sont des fonctions dont les noms commencent par test. Cette fonction vérifie la fonctionnalité de base du cache, en écrivant des valeurs puis en les lisant à nouveau.

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);
Copier

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 celle que nous avons lue. Nous pouvons ignorer le résultat de cache.cacheWrite car nous savons que les valeurs de cache sont assignées linéairement.

1 }
2 } // testCaching
3
4
5 // Cache the same value multiple times, ensure that the key stays
6 // the same
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
Copier

Tout d'abord, nous écrivons chaque valeur deux fois dans le cache et nous nous assurons que les valeurs sont les mêmes (ce qui signifie que la deuxième écriture ne s'est pas réellement produite).

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

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

1 // Read a uint from a memory buffer (to make sure we get back the parameters
2 // we sent out)
3 function toUint256(bytes memory _bytes, uint256 _start) internal pure
4 returns (uint256)
Copier

Lire un mot de 256 bits à partir de la mémoire tampon de bytes. Cette fonction utilitaire nous permet de vérifier que nous obtenons 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 }
Copier

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

1
2 return tempUint;
3 } // toUint256
4
5 // Function signature for fourParams(), courtesy of
6 // https://www.4byte.directory/signatures/?bytes4_signature=0x3edc1e6d
7 bytes4 constant FOUR_PARAMS = 0x3edc1e6d;
8
9 // Just some constant values to see we're getting the correct values back
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
Copier

Quelques constantes dont nous avons besoin pour les tests.

1 function testReadParam() public {
Copier

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

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

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 <address>.call()(opens in a new tab) de bas niveau. Ce mécanisme prend une mémoire bytes en entrée et renvoie celle-ci (ainsi qu'une valeur booléenne) en sortie.

1 // First call, the cache is empty
2 _callInput = bytes.concat(
3 FOUR_PARAMS,
Copier

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

1 // First value, add it to the cache
2 cache.INTO_CACHE(),
3 bytes32(VAL_A),
Copier

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

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

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

1 assertEq(_success, true);
Copier

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

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

Nous commençons avec un cache vide, puis ajoutons VAL_A suivi de VAL_C. Nous nous attendrions à ce que le premier ait la valeur 1 et le second la valeur 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);

Le résultat est composé des quatre paramètres. Ici, nous vérifions qu'il est correct.

1 // Second call, we can use the cache
2 _callInput = bytes.concat(
3 FOUR_PARAMS,
4
5 // First value in the Cache
6 bytes1(0x01),
Copier

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

1 // Second value, don't add it to the cache
2 cache.DONT_CACHE(),
3 bytes32(VAL_B),
4
5 // Third and fourth values, same value
6 bytes1(0x02),
7 bytes1(0x02)
8 );
9 .
10 .
11 .
12 } // testReadParam
Afficher tout
Copier

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

1 function testEncodeVal() public {
Copier

Cette fonction est similaire à testReadParam, à la différence près que nous utilisons encodeVal() au lieu d'écrire explicitement les paramètres.

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
Copier

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+33*4. Pour le deuxième, où chaque valeur est déjà dans le cache, elle est de 4+1*4.

1 // Test encodeVal when the key is more than a single byte
2 // Maximum three bytes because filling the cache to four bytes takes
3 // too long.
4 function testEncodeValBig() public {
5 // Put a number of values in the cache.
6 // To keep things simple, use key n for value n.
7 for(uint i=1; i<0x1FFF; i++) {
8 cache.cacheWrite(i);
9 }
Afficher tout
Copier

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

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

1 .
2 .
3 .
4
5 _callInput = bytes.concat(
6 FOUR_PARAMS,
7 cache.encodeVal(0x000F), // One byte 0x0F
8 cache.encodeVal(0x0010), // Two bytes 0x1010
9 cache.encodeVal(0x0100), // Two bytes 0x1100
10 cache.encodeVal(0x1000) // Three bytes 0x201000
11 );
Afficher tout
Copier

Testez des valeurs d'un octet, de deux octets et de trois octets. Nous n'allons 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 // Test what with an excessively small buffer we get a revert
9 function testShortCalldata() public {
Afficher tout
Copier

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

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

Puisqu'il revient, le résultat que nous devrions obtenir est false.

1 // Call with cache keys that aren't there
2 function testNoCacheKey() public {
3 .
4 .
5 .
6 _callInput = bytes.concat(
7 FOUR_PARAMS,
8
9 // First value, add it to the cache
10 cache.INTO_CACHE(),
11 bytes32(VAL_A),
12
13 // Second value
14 bytes1(0x0F),
15 bytes2(0x1234),
16 bytes11(0xA10102030405060708090A)
17 );
Afficher tout

Cette fonction obtient quatre paramètres parfaitement légitimes, à l'exception du fait que le cache est vide, il n'y a donc aucune valeur à lire.

1 .
2 .
3 .
4 // Test what with an excessively long buffer everything works file
5 function testLongCalldata() public {
6 address _cacheAddr = address(cache);
7 bool _success;
8 bytes memory _callInput;
9 bytes memory _callOutput;
10
11 // First call, the cache is empty
12 _callInput = bytes.concat(
13 FOUR_PARAMS,
14
15 // First value, add it to the cache
16 cache.INTO_CACHE(), bytes32(VAL_A),
17
18 // Second value, add it to the cache
19 cache.INTO_CACHE(), bytes32(VAL_B),
20
21 // Third value, add it to the cache
22 cache.INTO_CACHE(), bytes32(VAL_C),
23
24 // Fourth value, add it to the cache
25 cache.INTO_CACHE(), bytes32(VAL_D),
26
27 // And another value for "good luck"
28 bytes4(0x31112233)
29 );
Afficher tout
Copier

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é un rejet 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
Copier

Exemple d'application

Écrire des tests en Solidity c'est très bien, mais en fin de compte, une DApp doit être capable de traiter les demandes venant de la chaîne pour être utile. Cet article montre comment utiliser la mise en cache dans une DApp avec WORM, qui signifie « Écrire Une Fois, Lire Plusieurs fois » (en anglais "Write Once, Read Many"). Si une clé n'a pas encore été assignée, vous pouvez y écrire une valeur. Si la clé a déjà été assignée, cela annule l'écriture.

Le contrat

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

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

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
Copier

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

1 // Make it easier to call us
2 // Function signature for writeEntryCached(), courtesy of
3 // https://www.4byte.directory/signatures/?bytes4_signature=0xe4e4f2d3
4 bytes4 constant public WRITE_ENTRY_CACHED = 0xe4e4f2d3;
Copier

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 ABI. Avoir cette valeur constante rend plus facile 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 la fonction getter pour cela, worm.WRITE_ENTRY_CACHED().

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

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

Code de test

Voici le code de test pour le contrat(opens in a new tab). Encore une fois, regardons uniquement 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);
Copier

Ceci (vm.expectRevert)(opens in a new tab) est la façon dont nous spécifions dans un test Foundry que le prochain appel devrait échouer, et la raison de cet échec. Cela s'applique lorsque nous utilisons la syntaxe <contract>.<nom de la fonction>() 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);
Copier

Ici, nous utilisons le fait que cacheWrite renvoie la clé du cache. Ce n'est pas quelque chose que nous prévoyons d'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 résultats sont censés être émis sous forme d'événements. Ainsi, la valeur de retour de cacheWrite n'est accessible qu'à partir du code sur la chaîne, et le code sur la chaîne n'a pas besoin de mise en cache des paramètres.

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

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

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

Puisque nous utilisons la fonction de bas niveau <address>.call(), nous ne pouvons pas utiliser vm.expectRevert() et devons regarder la valeur de résultat booléen 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
Copier

C'est ainsi que nous vérifions que le code émet correctement un événement(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 couper 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 packages 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, est à 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, affichant les données d'appel et un lien vers la transaction sur Etherscan. Ensuite, elle lit cette entrée, affiche la clé qu'elle utilise et les valeurs de l'entrée (valeur, numéro de bloc et auteur).

La plupart des clients sont du JavaScript 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 // Need a new key every time
8 const key = await worm.encodeVal(Number(new Date()))

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

1const val = await worm.encodeVal("0x600D")
2
3// Write an entry
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 suivie 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 normalement pas appeler une fonction de mise en cache. Au lieu de cela, nous devons utiliser un mécanisme de bas niveau.

1 .
2 .
3 .
4 // Read the entry just written
5 const realKey = '0x' + key.slice(4) // remove the FF flag
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 dans cet article est une preuve de concept, le but étant de rendre l'idée facile à comprendre. Pour un système exploitable, vous devriez peut-être mettre en œuvre certaines fonctionnalités supplémentaires :

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

  • Au lieu d'un cache global, peut-être avoir une correspondance entre les utilisateurs et les caches. Différents utilisateurs utilisent différentes valeurs.

  • 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 fonctionnent selon 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 en deux octets. Le million de valeurs suivant est de trois octets, etc. Un système de production doit conserver les 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 souhaite envoyer des tokens. Cette adresse est l'une des premières utilisées dans l'application, donc la valeur encodée est 0x06. Il s'agit d'une fonction en lecture, pas d'une transaction, donc elle concerne uniquement Noam et le nœud qu'il utilise, et personne d'autre n'est au courant

    2. Paulo Proprio 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 tokens à 0x06. Ils vont à l'adresse 0x0000000000000000000000000de0b6b3a7640000, et comme personne ne connaît la clé privée de cette adresse, ils restent bloqués là. Noam n'est pas content.

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

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

Dernière modification: @XofEE(opens in a new tab), 18 janvier 2024

Ce tutoriel vous a été utile ?