Passer au contenu principal

Mise en cache à volonté

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

Lors de l'utilisation de rollup, le coût d'un octet dans la transaction est beaucoup plus élevé que le coût d'un créneau de stockage. Par conséquent, il est judicieux de mettre en cache autant d'informations que possible onchain.

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

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

Conception globale

Par souci de simplicité, nous supposerons que tous les paramètres de transaction sont des uint256, d'une longueur de 32 octets. Lorsque nous recevons une transaction, nous analyserons chaque paramètre comme suit :

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

    Octets dans les données d'appelClé de 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). Passons-le en revue ligne par ligne.

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;


contract Cache {

    bytes1 public constant INTO_CACHE = 0xFF;
    bytes1 public constant DONT_CACHE = 0xFE;

Ces constantes sont utilisées pour interpréter les cas particuliers où nous fournissons toutes les informations et souhaitons ou non qu'elles soient écrites dans le cache. L'écriture dans le cache nécessite deux opérations SSTORE (opens in a new tab) dans des créneaux de stockage précédemment inutilisés pour un coût de 22 100 gaz chacune, nous la rendons donc facultative.


    mapping(uint => uint) public val2key;

Un mapping (opens in a new tab) entre les valeurs et leurs clés. Cette information est nécessaire pour encoder les valeurs avant d'envoyer la transaction.

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

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

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

Lire une valeur depuis le cache.

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

Il ne sert à rien de placer la même valeur dans le cache plus d'une fois. Si la valeur s'y trouve déjà, il suffit de renvoyer la clé existante.

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

Je ne pense pas que nous aurons un jour 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 que "640 ko seraient toujours suffisants" (opens in a new tab). Ce test est très peu coûteux.

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

Ajouter la recherche inversée (de la valeur vers la clé).

        key2val.push(_value);

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

        return key2val.length;
    }  // cacheWrite

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

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

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

    {
        uint _retVal;

        require(length < 0x21,
            "_calldataVal length limit is 32 bytes");
        require(length + startByte <= msg.data.length,
            "_calldataVal trying to read beyond 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, autant les inclure.

        assembly {
            _retVal := calldataload(startByte)
        }

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.

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

Nous ne voulons pas nécessairement une valeur de 32 octets. Cela permet de se débarrasser des octets excédentaires.

        return _retVal;
    } // _calldataVal


    // Lire un seul paramètre à partir des données d'appel, en commençant à _fromByte
    function _readParam(uint _fromByte) internal
        returns (uint _nextByte, uint _parameterValue)
    {

Lire un seul paramètre à partir des données d'appel. Notez que nous devons renvoyer 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.

        // Le premier octet nous indique comment interpréter le reste
        uint8 _firstByte;

        _firstByte = uint8(_calldataVal(_fromByte, 1));

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

Prendre le quartet (opens in a new tab) inférieur et le combiner avec les autres octets pour lire la valeur depuis le cache.

Nous pourrions obtenir le nombre de paramètres que nous avons à 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.

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

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 renvoie afin que le test puisse vérifier qu'ils étaient corrects.

    // Obtenir une valeur, renvoyer les octets qui l'encoderont (en utilisant le cache si possible)
    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 renvoie les octets qui l'encodent. Cette fonction est une view, elle ne nécessite donc pas de transaction et, lorsqu'elle est appelée en externe, ne coûte aucun gaz.

        uint _key = val2key[_val];

        // La valeur n'est pas encore dans le cache, l'ajouter
        if (_key == 0)
            return bytes.concat(INTO_CACHE, bytes32(_val));

Dans l'EVM, tout stockage non initialisé est supposé être constitué de zéros. Donc, si nous cherchons la clé d'une valeur qui ne s'y trouve pas, nous obtenons un zéro. Dans ce cas, les octets qui l'encodent sont INTO_CACHE (elle sera donc mise en cache la prochaine fois), suivis de la valeur réelle.

        // Si la clé est <0x10, la renvoyer comme un seul octet
        if (_key < 0x10)
            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 qui peut être de n'importe quelle longueur. Malgré son nom, cela fonctionne très bien lorsqu'il est fourni avec un seul argument.

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

Lorsque nous avons une clé inférieure à 163, nous pouvons l'exprimer sur 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 plaçons simplement dans une valeur bytes2, qui peut être convertie en bytes.

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

        // Si nous arrivons ici, quelque chose ne va pas.
        revert("Error in encodeVal, should not happen");

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, nous ne pouvons donc même pas atteindre 14*25616 (qui aurait un premier octet de 0xFE, et ressemblerait donc à DONT_CACHE). Mais cela ne nous coûte pas grand-chose d'ajouter un test au cas où un futur programmeur introduirait un bug.

    } // encodeVal

}  // Cache

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 unitaires. Les tests pour la classe Cache se trouvent ici (opens in a new tab). Étant donné que le code de test est répétitif, comme le sont souvent les tests, cet article n'explique que les parties intéressantes.

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;

import "forge-std/Test.sol";


// Besoin d'exécuter `forge test -vv` pour la console.
import "forge-std/console.sol";

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

import "src/Cache.sol";

Nous devons connaître le contrat que nous testons.

contract CacheTest is Test {
    Cache cache;

    function setUp() public {
        cache = new Cache();
    }

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 mutuellement.

    function testCaching() public {

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 et en les relisant.

        for(uint i=1; i<5000; i++) {
            cache.cacheWrite(i*i);
        }

        for(uint i=1; i<5000; i++) {
            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 celle que nous lisons. 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.

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).

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

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

    // Lire un uint à partir d'un tampon mémoire (pour s'assurer que nous récupérons les paramètres
    // que nous avons envoyés)
    function toUint256(bytes memory _bytes, uint256 _start) internal pure
        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.

    {
        require(_bytes.length >= _start + 32, "toUint256_outOfBounds");
        uint256 tempUint;

        assembly {
            tempUint := mload(add(add(_bytes, 0x20), _start))
        }

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, donc pour obtenir l'octet numéro _start, nous devons calculer _bytes+32+_start.

Quelques constantes dont nous avons besoin pour les tests.

    function testReadParam() public {

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

        address _cacheAddr = address(cache);
        bool _success;
        bytes memory _callInput;
        bytes memory _callOutput;

Nous ne pouvons pas utiliser le mécanisme ABI normal pour appeler une fonction 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 renvoie (ainsi qu'une valeur booléenne) en sortie.

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

Il est utile pour le même contrat de prendre en charge à la fois les fonctions mises en cache (pour les appels directs 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 Solidity pour appeler la bonne fonction, au lieu de tout mettre dans une fonction fallback (opens in a new tab). Cela facilite grandement la composabilité. Un seul octet suffirait à 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 bugs.

            // Première valeur, l'ajouter au cache
            cache.INTO_CACHE(),
            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, à l'exception du fait 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.

             .
             .
             .
        );
        (_success, _callOutput) = _cacheAddr.call(_callInput);

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

        assertEq(_success, true);

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

        assertEq(cache.cacheRead(1), VAL_A);
        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.

assertEq(toUint256(_callOutput,0), VAL_A);
        assertEq(toUint256(_callOutput,32), VAL_B);
        assertEq(toUint256(_callOutput,64), VAL_C);
        assertEq(toUint256(_callOutput,96), VAL_C);

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

        // Deuxième appel, nous pouvons utiliser le cache
        _callInput = bytes.concat(
            FOUR_PARAMS,

            // Première valeur dans le Cache
            bytes1(0x01),

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

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

    function testEncodeVal() public {

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

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

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.

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

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

        .
        .
        .
        (_success, _callOutput) = _cacheAddr.call(_callInput);
        assertEq(_success, false);
    }   // testShortCalldata

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

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

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.

Un exemple d'application

Écrire des tests en Solidity est une très bonne chose, mais en fin de compte, une application décentralisée (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 couvrons donc que les parties intéressantes.

import "./Cache.sol";

contract WORM is Cache {

La façon la plus simple d'utiliser Cache est d'en hériter dans notre propre contrat.

    function writeEntryCached() external {
        uint[] memory params = _readParams(2);
        writeEntry(params[0], params[1]);
    }    // 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.

    // Rendre plus facile de nous appeler
    // Signature de fonction pour writeEntryCached(), avec l'aimable autorisation de
    // https://www.4byte.directory/signatures/?bytes4_signature=0xe4e4f2d3
    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 en externe, il est nécessaire d'utiliser sa fonction getter, worm.WRITE_ENTRY_CACHED().

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

La fonction de lecture est une view, 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 pour le contrat (opens in a new tab). Encore une fois, ne regardons que ce qui est intéressant.

    function testWReadWrite() public {
        worm.writeEntry(0xDEAD, 0x60A7);

        vm.expectRevert(bytes("entry already written"));
        worm.writeEntry(0xDEAD, 0xBEEF);

C'est ainsi (vm.expectRevert) (opens in a new tab) que nous spécifions dans un test Foundry que le prochain appel doit échouer, ainsi que la raison signalée pour un é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.).

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

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

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

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

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

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

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 est du code JavaScript que vous pouvez copier et coller dans votre propre application. La version originale de ce tutoriel déployait WORM sur Optimism Goerli, qui a depuis été retiré. Pour exécuter le client aujourd'hui, redéployez WORM sur un réseau OP Stack pris en charge tel que OP Sepolia (opens in a new tab), puis utilisez l'adresse du contrat résultante dans le client JavaScript.

Vous pouvez voir le code JavaScript pour le client ici (opens in a new tab). Le dépôt d'exemple a été écrit pour Optimism Goerli, donc avant de l'exécuter, mettez à jour le point de terminaison RPC et les URL de l'explorateur dans javascript/.env.example et javascript/index.js pour votre réseau cible. Pour l'utiliser :

  1. Clonez le dépôt git :

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

    cd javascript
    yarn
    
  3. Copiez le fichier de configuration :

    cp .env.example .env
    
  4. Modifiez .env pour votre configuration :

    ParamètreValeur
    MNEMONICLa phrase mnémonique pour un compte qui a suffisamment d'ETH pour payer une transaction. La documentation des faucets d'Optimism (opens in a new tab) liste les faucets de réseau de test actuels.
    OPTIMISM_GOERLI_URLL'URL RPC pour le réseau où vous redéployez WORM. Pour OP Sepolia, utilisez un point de terminaison RPC OP Sepolia tel que https://sepolia.optimism.io, ou un autre point de terminaison de votre fournisseur.
  5. Exécutez index.js.

    node 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 un explorateur de blocs. 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 majeure partie du client est du JavaScript d'application décentralisée (dapp) normal. Donc, encore une fois, nous ne passerons en revue que les parties intéressantes.

.
.
.
const main = async () => {
    const func = await worm.WRITE_ENTRY_CACHED()

    // Besoin d'une nouvelle clé à chaque fois
    const key = await worm.encodeVal(Number(new Date()))

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

const val = await worm.encodeVal("0x600D")

// Écrire une entrée
const 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 deux par 0x, nous devons supprimer ces en-têtes.

const tx = await worm.populateTransaction.writeEntryCached()
tx.data = calldata

sentTx = await wallet.sendTransaction(tx)

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

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 souhaiterez peut-être implémenter des fonctionnalités supplémentaires :

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

  • Au lieu d'un cache global, avoir peut-être un mapping 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 reposent sur un algorithme "premier arrivé, plus petite clé". Les seize premières valeurs peuvent être envoyées sous la forme d'un seul octet. Les 4080 valeurs suivantes peuvent être envoyées sur deux octets. Le million de valeurs suivant environ prend trois octets, etc. Un système de production devrait conserver des compteurs d'utilisation sur les entrées de cache et les réorganiser de sorte que les seize valeurs les plus courantes fassent un octet, les 4080 valeurs les plus courantes suivantes fassent deux octets, etc.

    Cependant, il s'agit d'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 jetons. Cette adresse est l'une des premières utilisées sur l'application, la valeur encodée est donc 0x06. Il s'agit d'une fonction view, et non d'une transaction, c'est donc entre Noam et le nœud qu'il utilise, et personne d'autre n'est au courant.

    2. Owen Propriétaire exécute l'opération de réorganisation du cache. Très peu de personnes utilisent réellement cette adresse, elle est donc désormais encodée sous la forme 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 y restent bloqués. 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 la mempool pendant la réorganisation du cache, mais vous devez en être conscient.

J'ai fait la démonstration de la mise en cache ici avec Optimism, car je suis un employé d'Optimism et 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 couche 1 (l1) constitue la dépense principale.

Voir ici pour plus de mes travaux (opens in a new tab).