Tout ce qui se cache
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 :
Si le premier octet est
0xFF
, prenez les 32 octets suivants comme valeur de paramètre et écrivez-la dans le cache.Si le premier octet est
0xFE
, prenez les 32 octets suivants comme valeur de paramètre, mais ne l'écrivez pas dans le cache.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'appel Clé de la mise en cache 0x0F 0x0F 0x10,0x10 0x10 0x12,0xAC 0x02AC 0x2D,0xEA, 0xD6 0x0DEAD6
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: UNLICENSED2pragma solidity ^0.8.13;345contract Cache {67 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.
12 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 preserve2 // 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 } // cacheReadCopier
Lire une valeur dans le cache.
1 // Write a value to the cache if it's not there already2 // Only public to enable the test to work3 function cacheWrite(uint _value) public returns (uint) {4 // If the value is already in the cache, return the current key5 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 can2 // hold is 0x0D followed by 15 0xFF's. If the cache length is already that3 // large, fail.4 // 1 2 3 4 5 6 7 8 9 A B C D E F5 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 key2 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 } // cacheWriteCopier
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;34 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 } // _calldataVal345 // Read a single parameter from the calldata, starting at _fromByte6 function _readParam(uint _fromByte) internal7 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 rest2 uint8 _firstByte;34 _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.
12 // Read the value, but do not write it to the cache3 if (_firstByte == uint8(DONT_CACHE))4 return(_fromByte+33, _calldataVal(_fromByte+1, 32));56 // Read the value, and write it to the cache7 if (_firstByte == uint8(INTO_CACHE)) {8 uint _param = _calldataVal(_fromByte+1, 32);9 cacheWrite(_param);10 return(_fromByte+33, _param);11 }1213 // If we got here it means that we need to read from the cache1415 // Number of extra bytes to read16 uint8 _extraBytes = _firstByte / 16;Afficher toutCopier
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);34 return (_fromByte+_extraBytes+1, cacheRead(_key));56 } // _readParam789 // Read n parameters (functions know how many parameters they expect)10 function _readParams(uint _paramNum) internal returns (uint[] memory) {Afficher toutCopier
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 read2 uint[] memory params = new uint[](_paramNum);34 // Parameters start at byte 4, before that it's the function signature5 uint _atByte = 4;67 for(uint i=0; i<_paramNum; i++) {8 (_atByte, params[i]) = _readParam(_atByte);9 }Afficher toutCopier
Lisez les paramètres jusqu'à obtenir le nombre requis. Si nous dépassons la fin du calldata, _readParams
annulera l'appel.
12 return(params);3 } // readParams45 // For testing _readParams, test reading four parameters6 function fourParam() public7 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 } // fourParamAfficher toutCopier
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];23 // The value isn't in the cache yet, add it4 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 byte2 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 0x1vvv2 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 and3 // simplicity.45 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 toutCopier
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 } // encodeVal23} // CacheCopier
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: UNLICENSED2pragma solidity ^0.8.13;34import "forge-std/Test.sol";567// 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;34 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 }45 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 } // testCaching345 // Cache the same value multiple times, ensure that the key stays6 // the same7 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 toutCopier
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 } // testRepeatCachingCopier
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 parameters2 // we sent out)3 function toUint256(bytes memory _bytes, uint256 _start) internal pure4 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;45 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
.
12 return tempUint;3 } // toUint25645 // Function signature for fourParams(), courtesy of6 // https://www.4byte.directory/signatures/?bytes4_signature=0x3edc1e6d7 bytes4 constant FOUR_PARAMS = 0x3edc1e6d;89 // Just some constant values to see we're getting the correct values back10 uint256 constant VAL_A = 0xDEAD60A7;11 uint256 constant VAL_B = 0xBEEF;12 uint256 constant VAL_C = 0x600D;13 uint256 constant VAL_D = 0x600D60A7;Afficher toutCopier
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 empty2 _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 cache2 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 cache2 _callInput = bytes.concat(3 FOUR_PARAMS,45 // First value in the Cache6 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 cache2 cache.DONT_CACHE(),3 bytes32(VAL_B),45 // Third and fourth values, same value6 bytes1(0x02),7 bytes1(0x02)8 );9 .10 .11 .12 } // testReadParamAfficher toutCopier
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 } // testEncodeValAfficher toutCopier
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 byte2 // Maximum three bytes because filling the cache to four bytes takes3 // 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 toutCopier
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 .45 _callInput = bytes.concat(6 FOUR_PARAMS,7 cache.encodeVal(0x000F), // One byte 0x0F8 cache.encodeVal(0x0010), // Two bytes 0x10109 cache.encodeVal(0x0100), // Two bytes 0x110010 cache.encodeVal(0x1000) // Three bytes 0x20100011 );Afficher toutCopier
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 } // testEncodeValBig678 // Test what with an excessively small buffer we get a revert9 function testShortCalldata() public {Afficher toutCopier
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 } // testShortCalldataCopier
Puisqu'il revient, le résultat que nous devrions obtenir est false
.
1 // Call with cache keys that aren't there2 function testNoCacheKey() public {3 .4 .5 .6 _callInput = bytes.concat(7 FOUR_PARAMS,89 // First value, add it to the cache10 cache.INTO_CACHE(),11 bytes32(VAL_A),1213 // Second value14 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 file5 function testLongCalldata() public {6 address _cacheAddr = address(cache);7 bool _success;8 bytes memory _callInput;9 bytes memory _callOutput;1011 // First call, the cache is empty12 _callInput = bytes.concat(13 FOUR_PARAMS,1415 // First value, add it to the cache16 cache.INTO_CACHE(), bytes32(VAL_A),1718 // Second value, add it to the cache19 cache.INTO_CACHE(), bytes32(VAL_B),2021 // Third value, add it to the cache22 cache.INTO_CACHE(), bytes32(VAL_C),2324 // Fourth value, add it to the cache25 cache.INTO_CACHE(), bytes32(VAL_D),2627 // And another value for "good luck"28 bytes4(0x31112233)29 );Afficher toutCopier
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 } // testLongCalldata78} // CacheTest9Afficher toutCopier
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";23contract 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 } // writeEntryCachedCopier
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 us2 // Function signature for writeEntryCached(), courtesy of3 // https://www.4byte.directory/signatures/?bytes4_signature=0xe4e4f2d34 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 view2 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);34 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);23 .4 .5 .67 _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 toutCopier
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 :
Clonez le dépôt git :
1git clone https://github.com/qbzzt/20220915-all-you-can-cache.gitInstallez les packages nécessaires :
1cd javascript2yarnCopiez le fichier de configuration :
1cp .env.example .envModifiez
.env
selon votre configuration :Paramètre Valeur MNEMONIC La 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_URL URL vers Optimism Goerli. Le point de terminaison public, https://goerli.optimism.io
, est à débit limité mais suffisant pour ce dont nous avons besoin iciExécutez
index.js
.1node index.jsCet 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()67 // Need a new key every time8 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")23// Write an entry4const 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 = calldata34sentTx = 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 written5 const realKey = '0x' + key.slice(4) // remove the FF flag6 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 :
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 enlecture
, pas d'une transaction, donc elle concerne uniquement Noam et le nœud qu'il utilise, et personne d'autre n'est au courantPaulo 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.
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: @nhsz(opens in a new tab), 15 août 2023