---
title: "Всё, что можно кэшировать"
description: "Узнайте, как создать и использовать контракт кэширования для удешевления транзакций в роллапах"
author: "Ори Померанц"
tags: ["уровень 2", "кэширование", "хранение", "масштабирование"]
skill: intermediate
breadcrumb: "Кэширование для роллапов"
published: 2022-09-15
lang: ru
---

При использовании роллапов стоимость байта в транзакции намного выше стоимости слота хранения. Поэтому имеет смысл кэшировать как можно больше информации ончейн.

В этой статье вы узнаете, как создать и использовать контракт кэширования таким образом, чтобы любое значение параметра, которое, вероятно, будет использоваться несколько раз, кэшировалось и было доступно для использования (после первого раза) с гораздо меньшим количеством байтов, а также как написать офчейн-код, использующий этот кэш.

Если вы хотите пропустить статью и просто посмотреть исходный код, [он находится здесь](https://github.com/qbzzt/20220915-all-you-can-cache). Стек разработки — [Foundry](https://getfoundry.sh/introduction/installation/).

## Общая архитектура {#overall-design}

Для простоты предположим, что все параметры транзакции имеют тип `uint256` длиной 32 байта. При получении транзакции мы будем анализировать каждый параметр следующим образом:

1. Если первый байт равен `0xFF`, берем следующие 32 байта как значение параметра и записываем его в кэш.

2. Если первый байт равен `0xFE`, берем следующие 32 байта как значение параметра, но _не_ записываем его в кэш.

3. Для любого другого значения берем старшие четыре бита как количество дополнительных байтов, а младшие четыре бита — как старшие биты ключа кэша. Вот несколько примеров:

   | Байты в данных вызова | Ключ кэша |
   | :---------------- | --------: |
   | 0x0F              |      0x0F |
   | 0x10,0x10         |      0x10 |
   | 0x12,0xAC         |    0x02AC |
   | 0x2D,0xEA, 0xD6   |  0x0DEAD6 |

## Управление кэшем {#cache-manipulation}

Кэш реализован в [`Cache.sol`](https://github.com/qbzzt/20220915-all-you-can-cache/blob/main/src/Cache.sol). Давайте разберем его построчно.

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


contract Cache {

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

Эти константы используются для интерпретации особых случаев, когда мы предоставляем всю информацию и хотим, чтобы она была записана в кэш или нет. Запись в кэш требует двух операций [`SSTORE`](https://www.evm.codes/#55) в ранее неиспользованные слоты хранения стоимостью 22100 газа каждая, поэтому мы делаем ее необязательной.

```solidity

    mapping(uint => uint) public val2key;
```

[Отображение (mapping)](https://www.geeksforgeeks.org/solidity/solidity-mappings/) между значениями и их ключами. Эта информация необходима для кодирования значений перед отправкой транзакции.

```solidity
    // В ячейке n хранится значение для ключа n+1, потому что нам нужно сохранить
    // ноль как «нет в кэше».
    uint[] public key2val;
```

Мы можем использовать массив для отображения ключей в значения, потому что мы сами назначаем ключи, и для простоты делаем это последовательно.

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

Чтение значения из кэша.

```solidity
    // Записать значение в кэш, если его там еще нет
    // Сделано public только для того, чтобы работал тест
    function cacheWrite(uint _value) public returns (uint) {
        // Если значение уже есть в кэше, вернуть текущий ключ
        if (val2key[_value] != 0) {
            return val2key[_value];
        }
```

Нет смысла помещать одно и то же значение в кэш более одного раза. Если значение уже там, просто возвращаем существующий ключ.

```solidity
        // Поскольку 0xFE — особый случай, самый большой ключ, который может
        // вместить кэш, — это 0x0D, за которым следуют 15 0xFF. Если длина кэша уже достигла этого
        // размера, завершить с ошибкой.
        //                              1 2 3 4 5 6 7 8 9 A B C D E F
        require(key2val.length+1 < 0x0DFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF,
            "cache overflow");
```

Не думаю, что у нас когда-нибудь будет такой большой кэш (примерно 1.8\*10<sup>37</sup> записей, для хранения которых потребуется около 10<sup>27</sup> ТБ). Однако я достаточно стар, чтобы помнить фразу [«640 КБ хватит всем»](https://quoteinvestigator.com/2011/09/08/640k-enough/). Эта проверка обходится очень дешево.

```solidity
        // Записать значение, используя следующий ключ
        val2key[_value] = key2val.length+1;
```

Добавление обратного поиска (от значения к ключу).

```solidity
        key2val.push(_value);
```

Добавление прямого поиска (от ключа к значению). Поскольку мы назначаем значения последовательно, мы можем просто добавить его после последнего значения массива.

```solidity
        return key2val.length;
    }  // cacheWrite
```

Возвращаем новую длину `key2val`, которая является ячейкой, где хранится новое значение.

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

Эта функция считывает значение из данных вызова произвольной длины (до 32 байт, размер слова).

```solidity
    {
        uint _retVal;

        require(length < 0x21,
            "_calldataVal length limit is 32 bytes");
        require(length + startByte <= msg.data.length,
            "_calldataVal trying to read beyond calldatasize");
```

Эта функция является внутренней, поэтому, если остальной код написан правильно, эти проверки не требуются. Однако они стоят недорого, так что пусть будут.

```solidity
        assembly {
            _retVal := calldataload(startByte)
        }
```

Этот код написан на [Yul](https://docs.soliditylang.org/en/v0.8.16/yul.html). Он считывает 32-байтовое значение из данных вызова. Это работает, даже если данные вызова заканчиваются до `startByte+32`, потому что неинициализированное пространство в EVM считается равным нулю.

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

Нам не обязательно нужно 32-байтовое значение. Это избавляет от лишних байтов.

```solidity
        return _retVal;
    } // _calldataVal


    // Считать один параметр из данных вызова, начиная с _fromByte
    function _readParam(uint _fromByte) internal
        returns (uint _nextByte, uint _parameterValue)
    {
```

Чтение одного параметра из данных вызова. Обратите внимание, что нам нужно вернуть не только прочитанное значение, но и расположение следующего байта, поскольку длина параметров может варьироваться от 1 до 33 байт.

```solidity
        // Первый байт указывает нам, как интерпретировать остальное
        uint8 _firstByte;

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

Solidity пытается уменьшить количество ошибок, запрещая потенциально опасные [неявные преобразования типов](https://docs.soliditylang.org/en/v0.8.16/types.html#implicit-conversions). Понижение типа, например, с 256 бит до 8 бит, должно быть явным.

```solidity

        // Считать значение, но не записывать его в кэш
        if (_firstByte == uint8(DONT_CACHE))
            return(_fromByte+33, _calldataVal(_fromByte+1, 32));

        // Считать значение и записать его в кэш
        if (_firstByte == uint8(INTO_CACHE)) {
            uint _param = _calldataVal(_fromByte+1, 32);
            cacheWrite(_param);
            return(_fromByte+33, _param);
        }

        // Если мы попали сюда, значит, нам нужно считать из кэша

        // Количество дополнительных байтов для чтения
        uint8 _extraBytes = _firstByte / 16;
```

Берем младший [полубайт (nibble)](https://en.wikipedia.org/wiki/Nibble) и объединяем его с остальными байтами для чтения значения из кэша.

```solidity
        uint _key = (uint256(_firstByte & 0x0F) << (8*_extraBytes)) +
            _calldataVal(_fromByte+1, _extraBytes);

        return (_fromByte+_extraBytes+1, cacheRead(_key));

    }  // _readParam


    // Считать n параметров (функции знают, сколько параметров они ожидают)
    function _readParams(uint _paramNum) internal returns (uint[] memory) {
```

Мы могли бы получить количество параметров из самих данных вызова, но вызывающие нас функции знают, сколько параметров они ожидают. Проще позволить им сообщить нам об этом.

```solidity
        // Считанные нами параметры
        uint[] memory params = new uint[](_paramNum);

        // Параметры начинаются с 4-го байта, до этого идет сигнатура функции
        uint _atByte = 4;

        for(uint i=0; i<_paramNum; i++) {
            (_atByte, params[i]) = _readParam(_atByte);
        }
```

Считываем параметры, пока не получим нужное количество. Если мы выйдем за пределы данных вызова, `_readParams` вызовет откат.

```solidity

        return(params);
    }   // readParams

    // Для тестирования _readParams, протестировать чтение четырех параметров
    function fourParam() public
        returns (uint256,uint256,uint256,uint256)
    {
        uint[] memory params;
        params = _readParams(4);
        return (params[0], params[1], params[2], params[3]);
    }    // fourParam
```

Одно из больших преимуществ Foundry заключается в том, что он позволяет писать тесты на Solidity ([см. Тестирование кэша ниже](#testing-the-cache)). Это значительно упрощает модульное тестирование. Это функция, которая считывает четыре параметра и возвращает их, чтобы тест мог убедиться в их правильности.

```solidity
    // Получить значение, вернуть байты, которые его закодируют (используя кэш, если возможно)
    function encodeVal(uint _val) public view returns(bytes memory) {
```

`encodeVal` — это функция, которую вызывает офчейн-код для помощи в создании данных вызова, использующих кэш. Она получает одно значение и возвращает байты, которые его кодируют. Эта функция имеет тип `view`, поэтому она не требует транзакции и при внешнем вызове не расходует газ.

```solidity
        uint _key = val2key[_val];

        // Значения еще нет в кэше, добавить его
        if (_key == 0)
            return bytes.concat(INTO_CACHE, bytes32(_val));
```

В [EVM](/developers/docs/evm/) все неинициализированное хранилище считается заполненным нулями. Поэтому, если мы ищем ключ для значения, которого там нет, мы получаем ноль. В этом случае байты, которые его кодируют, будут `INTO_CACHE` (чтобы оно было кэшировано в следующий раз), за которыми следует само значение.

```solidity
        // Если ключ <0x10, вернуть его как один байт
        if (_key < 0x10)
            return bytes.concat(bytes1(uint8(_key)));
```

Одиночные байты — самые простые. Мы просто используем [`bytes.concat`](https://docs.soliditylang.org/en/v0.8.16/types.html#the-functions-bytes-concat-and-string-concat), чтобы превратить тип `bytes<n>` в массив байтов, который может быть любой длины. Несмотря на название, он отлично работает, когда предоставляется только один аргумент.

```solidity
        // Двухбайтовое значение, закодированное как 0x1vvv
        if (_key < 0x1000)
            return bytes.concat(bytes2(uint16(_key) | 0x1000));
```

Когда у нас есть ключ, который меньше 16<sup>3</sup>, мы можем выразить его в двух байтах. Сначала мы преобразуем `_key`, которое является 256-битным значением, в 16-битное значение и используем логическое ИЛИ, чтобы добавить количество дополнительных байтов к первому байту. Затем мы просто приводим его к значению `bytes2`, которое можно преобразовать в `bytes`.

```solidity
        // Вероятно, есть умный способ выполнить следующие строки в виде цикла,
        // но это функция view, поэтому я оптимизирую время программиста и
        // простоту.

        if (_key < 16*256**2)
            return bytes.concat(bytes3(uint24(_key) | (0x2 * 16 * 256**2)));
        if (_key < 16*256**3)
            return bytes.concat(bytes4(uint32(_key) | (0x3 * 16 * 256**3)));
             .
             .
             .
        if (_key < 16*256**14)
            return bytes.concat(bytes15(uint120(_key) | (0xE * 16 * 256**14)));
        if (_key < 16*256**15)
            return bytes.concat(bytes16(uint128(_key) | (0xF * 16 * 256**15)));
```

Остальные значения (3 байта, 4 байта и т. д.) обрабатываются аналогичным образом, только с другими размерами полей.

```solidity
        // Если мы попали сюда, что-то не так.
        revert("Error in encodeVal, should not happen");
```

Если мы дошли до этого места, значит, мы получили ключ, который не меньше 16\*256<sup>15</sup>. Но `cacheWrite` ограничивает ключи, поэтому мы даже не можем дойти до 14\*256<sup>16</sup> (у которого первый байт был бы 0xFE, поэтому он выглядел бы как `DONT_CACHE`). Но нам ничего не стоит добавить проверку на случай, если будущий программист допустит ошибку.

```solidity
    } // encodeVal

}  // Cache
```

### Тестирование кэша {#testing-the-cache}

Одно из преимуществ Foundry заключается в том, что [он позволяет писать тесты на Solidity](https://getfoundry.sh/forge/tests/overview/), что упрощает написание модульных тестов. Тесты для класса `Cache` находятся [здесь](https://github.com/qbzzt/20220915-all-you-can-cache/blob/main/test/Cache.t.sol). Поскольку код тестирования повторяется, как это обычно бывает с тестами, в этой статье объясняются только самые интересные части.

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

import "forge-std/Test.sol";


// Нужно запустить `forge test -vv` для вывода в консоль.
import "forge-std/console.sol";
```

Это просто шаблонный код, необходимый для использования пакета тестирования и `console.log`.

```solidity
import "src/Cache.sol";
```

Нам нужно знать контракт, который мы тестируем.

```solidity
contract CacheTest is Test {
    Cache cache;

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

Функция `setUp` вызывается перед каждым тестом. В данном случае мы просто создаем новый кэш, чтобы наши тесты не влияли друг на друга.

```solidity
    function testCaching() public {
```

Тесты — это функции, имена которых начинаются с `test`. Эта функция проверяет базовую функциональность кэша: запись значений и их повторное чтение.

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

        for(uint i=1; i<5000; i++) {
            assertEq(cache.cacheRead(i), i*i);
```

Вот как выполняется само тестирование с использованием [функций `assert...`](https://getfoundry.sh/reference/forge-std/std-assertions/). В данном случае мы проверяем, что записанное нами значение совпадает с прочитанным. Мы можем отбросить результат `cache.cacheWrite`, потому что знаем, что ключи кэша назначаются линейно.

```solidity
        }
    }    // testCaching


    // Кэшировать одно и то же значение несколько раз, убедиться, что ключ остается
    // тем же
    function testRepeatCaching() public {
        for(uint i=1; i<100; i++) {
            uint _key1 = cache.cacheWrite(i);
            uint _key2 = cache.cacheWrite(i);
            assertEq(_key1, _key2);
        }
```

Сначала мы дважды записываем каждое значение в кэш и убеждаемся, что ключи совпадают (это означает, что вторая запись на самом деле не произошла).

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

Теоретически может существовать ошибка, которая не влияет на последовательные записи в кэш. Поэтому здесь мы выполняем несколько непоследовательных записей и видим, что значения по-прежнему не перезаписываются.

```solidity
    // Считать uint из буфера памяти (чтобы убедиться, что мы получаем обратно параметры,
    // которые мы отправили)
    function toUint256(bytes memory _bytes, uint256 _start) internal pure
        returns (uint256)
```

Чтение 256-битного слова из буфера `bytes memory`. Эта служебная функция позволяет нам убедиться, что мы получаем правильные результаты при выполнении вызова функции, использующей кэш.

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

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

Yul не поддерживает структуры данных сложнее `uint256`, поэтому при обращении к более сложной структуре данных, такой как буфер памяти `_bytes`, вы получаете адрес этой структуры. Solidity хранит значения `bytes memory` в виде 32-байтового слова, содержащего длину, за которым следуют сами байты, поэтому для получения байта с номером `_start` нам нужно вычислить `_bytes+32+_start`.

```solidity

        return tempUint;
    }     // toUint256

    // Сигнатура функции для fourParams(), предоставлена
    // https://www.4byte.directory/signatures/?bytes4_signature=0x3edc1e6d
    bytes4 constant FOUR_PARAMS = 0x3edc1e6d;

    // Просто некоторые константные значения, чтобы убедиться, что мы получаем правильные значения обратно
    uint256 constant VAL_A = 0xDEAD60A7;
    uint256 constant VAL_B =     0xBEEF;
    uint256 constant VAL_C =     0x600D;
    uint256 constant VAL_D = 0x600D60A7;
```

Некоторые константы, необходимые нам для тестирования.

```solidity
    function testReadParam() public {
```

Вызов `fourParams()`, функции, использующей `readParams`, для проверки правильности чтения параметров.

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

Мы не можем использовать обычный механизм ABI для вызова функции с использованием кэша, поэтому нам нужно использовать низкоуровневый механизм [`<address>.call()`](https://docs.soliditylang.org/en/v0.8.16/types.html#members-of-addresses). Этот механизм принимает `bytes memory` в качестве входных данных и возвращает его же (а также логическое значение) в качестве выходных данных.

```solidity
        // Первый вызов, кэш пуст
        _callInput = bytes.concat(
            FOUR_PARAMS,
```

Полезно, чтобы один и тот же контракт поддерживал как кэшированные функции (для вызовов непосредственно из транзакций), так и некэшированные функции (для вызовов из других смарт-контрактов). Для этого нам нужно продолжать полагаться на механизм Solidity для вызова правильной функции, вместо того чтобы помещать все в [функцию `fallback`](https://docs.soliditylang.org/en/v0.8.16/contracts.html#fallback-function). Это значительно упрощает композируемость. В большинстве случаев для идентификации функции было бы достаточно одного байта, поэтому мы тратим впустую три байта (16\*3=48 газа). Однако на момент написания этой статьи эти 48 единиц газа стоят 0,07 цента, что является разумной платой за более простой и менее подверженный ошибкам код.

```solidity
            // Первое значение, добавить его в кэш
            cache.INTO_CACHE(),
            bytes32(VAL_A),
```

Первое значение: флаг, указывающий, что это полное значение, которое необходимо записать в кэш, за которым следуют 32 байта значения. Остальные три значения аналогичны, за исключением того, что `VAL_B` не записывается в кэш, а `VAL_C` является как третьим, так и четвертым параметром.

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

Здесь мы фактически вызываем контракт `Cache`.

```solidity
        assertEq(_success, true);
```

Мы ожидаем, что вызов будет успешным.

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

Мы начинаем с пустого кэша, а затем добавляем `VAL_A`, за которым следует `VAL_C`. Мы ожидаем, что первый получит ключ 1, а второй — 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);
```

На выходе получаем четыре параметра. Здесь мы проверяем их правильность.

```solidity
        // Второй вызов, мы можем использовать кэш
        _callInput = bytes.concat(
            FOUR_PARAMS,

            // Первое значение в кэше
            bytes1(0x01),
```

Ключи кэша меньше 16 занимают всего один байт.

```solidity
            // Второе значение, не добавлять его в кэш
            cache.DONT_CACHE(),
            bytes32(VAL_B),

            // Третье и четвертое значения, одно и то же значение
            bytes1(0x02),
            bytes1(0x02)
        );
        .
        .
        .
    }   // testReadParam
```

Проверки после вызова идентичны проверкам после первого вызова.

```solidity
    function testEncodeVal() public {
```

Эта функция похожа на `testReadParam`, за исключением того, что вместо явной записи параметров мы используем `encodeVal()`.

```solidity
        .
        .
        .
        _callInput = bytes.concat(
            FOUR_PARAMS,
            cache.encodeVal(VAL_A),
            cache.encodeVal(VAL_B),
            cache.encodeVal(VAL_C),
            cache.encodeVal(VAL_D)
        );
        .
        .
        .
        assertEq(_callInput.length, 4+1*4);
    }   // testEncodeVal
```

Единственная дополнительная проверка в `testEncodeVal()` — это проверка правильности длины `_callInput`. Для первого вызова она равна 4+33\*4. Для второго, где каждое значение уже находится в кэше, она равна 4+1\*4.

```solidity
    // Протестировать encodeVal, когда ключ занимает больше одного байта
    // Максимум три байта, потому что заполнение кэша до четырех байтов занимает
    // слишком много времени.
    function testEncodeValBig() public {
        // Поместить несколько значений в кэш.
        // Для простоты использовать ключ n для значения n.
        for(uint i=1; i<0x1FFF; i++) {
            cache.cacheWrite(i);
        }
```

Функция `testEncodeVal` выше записывает в кэш только четыре значения, поэтому [часть функции, которая работает с многобайтовыми значениями](https://github.com/qbzzt/20220915-all-you-can-cache/blob/main/src/Cache.sol#L144-L171), не проверяется. Но этот код сложен и подвержен ошибкам.

Первая часть этой функции представляет собой цикл, который по порядку записывает все значения от 1 до 0x1FFF в кэш, чтобы мы могли закодировать эти значения и знать, куда они попадают.

```solidity
        .
        .
        .

        _callInput = bytes.concat(
            FOUR_PARAMS,
            cache.encodeVal(0x000F),   // Один байт        0x0F
            cache.encodeVal(0x0010),   // Два байта     0x1010
            cache.encodeVal(0x0100),   // Два байта     0x1100
            cache.encodeVal(0x1000)    // Три байта 0x201000
        );
```

Тестирование однобайтовых, двухбайтовых и трехбайтовых значений. Мы не тестируем дальше, потому что потребовалось бы слишком много времени для записи достаточного количества элементов стека (как минимум 0x10000000, примерно четверть миллиарда).

```solidity
        .
        .
        .
        .
    }    // testEncodeValBig


    // Проверить, что при слишком маленьком буфере мы получаем откат
    function testShortCalldata() public {
```

Проверка того, что происходит в нештатной ситуации, когда параметров недостаточно.

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

Поскольку происходит откат, мы должны получить результат `false`.

```
// Вызов с ключами кэша, которых нет
    function testNoCacheKey() public {
        .
        .
        .
        _callInput = bytes.concat(
            FOUR_PARAMS,

            // Первое значение, добавляем его в кэш
            cache.INTO_CACHE(),
            bytes32(VAL_A),

            // Второе значение
            bytes1(0x0F),
            bytes2(0x1234),
            bytes11(0xA10102030405060708090A)
        );
```

Эта функция получает четыре совершенно правильных параметра, за исключением того, что кэш пуст, поэтому там нет значений для чтения.

```solidity
        .
        .
        .
    // Проверить, что при слишком длинном буфере все работает нормально
    function testLongCalldata() public {
        address _cacheAddr = address(cache);
        bool _success;
        bytes memory _callInput;
        bytes memory _callOutput;

        // Первый вызов, кэш пуст
        _callInput = bytes.concat(
            FOUR_PARAMS,

            // Первое значение, добавить его в кэш
            cache.INTO_CACHE(), bytes32(VAL_A),

            // Второе значение, добавить его в кэш
            cache.INTO_CACHE(), bytes32(VAL_B),

            // Третье значение, добавить его в кэш
            cache.INTO_CACHE(), bytes32(VAL_C),

            // Четвертое значение, добавить его в кэш
            cache.INTO_CACHE(), bytes32(VAL_D),

            // И еще одно значение «на удачу»
            bytes4(0x31112233)
        );
```

Эта функция отправляет пять значений. Мы знаем, что пятое значение игнорируется, поскольку оно не является допустимой записью кэша, что вызвало бы откат, если бы оно не было включено.

```solidity
        (_success, _callOutput) = _cacheAddr.call(_callInput);
        assertEq(_success, true);
        .
        .
        .
    }   // testLongCalldata

}        // CacheTest

```

## Пример приложения {#a-sample-app}

Писать тесты на Solidity — это, конечно, хорошо, но в конечном итоге децентрализованное приложение (dapp) должно уметь обрабатывать запросы извне цепи, чтобы быть полезным. В этой статье демонстрируется, как использовать кэширование в dapp с помощью `WORM`, что означает «Write Once, Read Many» (Запиши один раз, читай много раз). Если ключ еще не записан, вы можете записать в него значение. Если ключ уже записан, вы получаете откат.

### Контракт {#the-contract}

[Вот этот контракт](https://github.com/qbzzt/20220915-all-you-can-cache/blob/main/src/WORM.sol). Он в основном повторяет то, что мы уже сделали с `Cache` и `CacheTest`, поэтому мы рассмотрим только интересные части.

```solidity
import "./Cache.sol";

contract WORM is Cache {
```

Самый простой способ использовать `Cache` — унаследовать его в нашем собственном контракте.

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

Эта функция похожа на `fourParam` в `CacheTest` выше. Поскольку мы не следуем спецификациям ABI, лучше не объявлять никаких параметров в функции.

```solidity
    // Сделать так, чтобы нас было проще вызвать
    // Сигнатура функции для writeEntryCached(), предоставлена
    // https://www.4byte.directory/signatures/?bytes4_signature=0xe4e4f2d3
    bytes4 constant public WRITE_ENTRY_CACHED = 0xe4e4f2d3;
```

Внешнему коду, вызывающему `writeEntryCached`, потребуется вручную создавать данные вызова вместо использования `worm.writeEntryCached`, поскольку мы не следуем спецификациям ABI. Наличие этого константного значения просто упрощает его написание.

Обратите внимание, что хотя мы определяем `WRITE_ENTRY_CACHED` как переменную состояния, для ее чтения извне необходимо использовать функцию-геттер для нее — `worm.WRITE_ENTRY_CACHED()`.

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

Функция чтения имеет тип `view`, поэтому она не требует транзакции и не расходует газ. В результате нет никакой выгоды от использования кэша для параметра. С функциями view лучше использовать стандартный механизм, который проще.

### Код тестирования {#the-testing-code}

[Это код тестирования для контракта](https://github.com/qbzzt/20220915-all-you-can-cache/blob/main/test/WORM.t.sol). Опять же, давайте посмотрим только на то, что интересно.

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

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

[Вот так (`vm.expectRevert`)](https://book.getfoundry.sh/cheatcodes/expect-revert#expectrevert) мы указываем в тесте Foundry, что следующий вызов должен завершиться ошибкой, и сообщаем причину сбоя. Это применимо, когда мы используем синтаксис `<contract>.<function name>()`, а не создаем данные вызова и не вызываем контракт с использованием низкоуровневого интерфейса (`<contract>.call()` и т. д.).

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

Здесь мы используем тот факт, что `cacheWrite` возвращает ключ кэша. Это не то, что мы ожидали бы использовать в рабочей среде, потому что `cacheWrite` изменяет состояние и, следовательно, может быть вызван только во время транзакции. Транзакции не имеют возвращаемых значений; если у них есть результаты, эти результаты должны генерироваться как события. Таким образом, возвращаемое значение `cacheWrite` доступно только из ончейн-кода, а ончейн-коду не нужно кэширование параметров.

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

Вот как мы сообщаем Solidity, что хотя `<contract address>.call()` имеет два возвращаемых значения, нас интересует только первое.

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

Поскольку мы используем низкоуровневую функцию `<address>.call()`, мы не можем использовать `vm.expectRevert()` и должны смотреть на логическое значение успеха, которое мы получаем от вызова.

```solidity
    event EntryWritten(uint indexed key, uint indexed value);

        .
        .
        .

        _callInput = bytes.concat(
            worm.WRITE_ENTRY_CACHED(), worm.encodeVal(a), worm.encodeVal(b));
        vm.expectEmit(true, true, false, false);
        emit EntryWritten(a, b);
        (_success,) = address(worm).call(_callInput);
```

Таким образом мы проверяем, что код [правильно генерирует событие](https://getfoundry.sh/reference/cheatcodes/expect-emit/) в Foundry.

### Клиент {#the-client}

Чего вы не получите с тестами на Solidity, так это кода на JavaScript, который можно скопировать и вставить в собственное приложение. В оригинальной версии этого руководства контракт WORM развертывался в сети Optimism Гёрли, которая с тех пор была выведена из эксплуатации. Чтобы запустить клиент сегодня, повторно разверните WORM в поддерживаемой сети OP Stack, такой как [OP Sepolia](https://docs.optimism.io/op-stack/introduction/op-stack), а затем используйте полученный адрес контракта в клиенте на JavaScript.

[Код клиента на JavaScript можно посмотреть здесь](https://github.com/qbzzt/20220915-all-you-can-cache/blob/main/javascript/index.js). Пример репозитория был написан для Optimism Гёрли, поэтому перед его запуском обновите URL-адреса конечной точки RPC и обозревателя в файлах `javascript/.env.example` и `javascript/index.js` для вашей целевой сети. Чтобы использовать его:

1. Клонируйте git-репозиторий:

   ```sh
   git clone https://github.com/qbzzt/20220915-all-you-can-cache.git
   ```

2. Установите необходимые пакеты:

   ```sh
   cd javascript
   yarn
   ```

3. Скопируйте файл конфигурации:

   ```sh
   cp .env.example .env
   ```

4. Отредактируйте `.env` для вашей конфигурации:

   | Параметр            | Значение                                                                                                                                                               |
   | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
   | MNEMONIC            | Мнемоническая фраза для аккаунта, на котором достаточно ETH для оплаты транзакции. В [документации по кранам Optimism](https://docs.optimism.io/app-developers/tools/faucets) перечислены текущие краны тестовой сети. |
   | OPTIMISM_GOERLI_URL | URL-адрес RPC для сети, в которой вы повторно развертываете WORM. Для OP Sepolia используйте конечную точку RPC OP Sepolia, например `https://sepolia.optimism.io`, или другую конечную точку от вашего провайдера.        |

5. Запустите `index.js`.

   ```sh
   node index.js
   ```

   Этот пример приложения сначала создает запись в WORM, отображая данные вызова и ссылку на транзакцию в обозревателе блоков. Затем оно считывает эту запись и отображает используемый ключ и значения в записи (значение, номер блока и автора).

Большая часть клиента — это обычный код децентрализованного приложения (dapp) на JavaScript. Поэтому мы снова рассмотрим только самые интересные части.

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

    // Каждый раз нужен новый ключ
    const key = await worm.encodeVal(Number(new Date()))
```

В каждый слот можно записать только один раз, поэтому мы используем временную метку, чтобы убедиться, что мы не используем слоты повторно.

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

// Записать запись
const calldata = func + key.slice(2) + val.slice(2)
```

Ethers ожидает, что данные вызова будут шестнадцатеричной строкой: `0x`, за которым следует четное количество шестнадцатеричных цифр. Поскольку `key` и `val` начинаются с `0x`, нам нужно удалить эти заголовки.

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

sentTx = await wallet.sendTransaction(tx)
```

Как и в случае с кодом тестирования на Solidity, мы не можем вызвать кэшированную функцию обычным способом. Вместо этого нам нужно использовать низкоуровневый механизм.

```javascript
    .
    .
    .
    // Считать только что записанную запись
    const realKey = '0x' + key.slice(4)  // удалить флаг FF
    const entryRead = await worm.readEntry(realKey)
    .
    .
    .
```

Для чтения записей мы можем использовать обычный механизм. Нет необходимости использовать кэширование параметров с функциями `view`.
## Заключение {#conclusion}

Код в этой статье является проверкой концепции (proof of concept), цель которой — сделать идею простой для понимания. Для готовой к использованию в рабочей среде системы вы, возможно, захотите реализовать некоторые дополнительные функции:

- Обработка значений, которые не являются `uint256`. Например, строк.
- Вместо глобального кэша, возможно, стоит создать отображение между пользователями и кэшами. Разные пользователи используют разные значения.
- Значения, используемые для адресов, отличаются от значений, используемых для других целей. Возможно, имеет смысл создать отдельный кэш только для адресов.
- В настоящее время ключи кэша работают по алгоритму «первым пришел — наименьший ключ». Первые шестнадцать значений могут быть отправлены как один байт. Следующие 4080 значений могут быть отправлены как два байта. Следующий примерно миллион значений — это три байта и т. д. Рабочая система должна вести счетчики использования записей кэша и реорганизовывать их так, чтобы шестнадцать _наиболее часто используемых_ значений занимали один байт, следующие 4080 наиболее часто используемых значений — два байта и т. д.

  Однако это потенциально опасная операция. Представьте себе следующую последовательность событий:

  1. Наивный Ноам вызывает `encodeVal`, чтобы закодировать адрес, на который он хочет отправить токены. Этот адрес является одним из первых, используемых в приложении, поэтому закодированное значение равно 0x06. Это функция `view`, а не транзакция, поэтому она происходит между Ноамом и узлом, который он использует, и никто другой об этом не знает.

  2. Владелец Оуэн запускает операцию изменения порядка кэша. Очень мало людей на самом деле используют этот адрес, поэтому теперь он закодирован как 0x201122. Другому значению, 10<sup>18</sup>, присваивается 0x06.

  3. Наивный Ноам отправляет свои токены на 0x06. Они уходят на адрес `0x0000000000000000000000000de0b6b3a7640000`, и поскольку никто не знает приватный ключ для этого адреса, они просто застревают там. Ноам _не счастлив_.

  Существуют способы решения этой проблемы, а также связанной с ней проблемы транзакций, которые находятся в мемпуле во время изменения порядка кэша, но вы должны знать о ней.

Я продемонстрировал здесь кэширование с помощью Optimism, потому что я являюсь сотрудником Optimism, и это роллап, который я знаю лучше всего. Но это должно работать с любым роллапом, который взимает минимальную плату за внутреннюю обработку, так что по сравнению с этим запись данных транзакции на уровень 1 (l1) является основной статьей расходов.

[Смотрите здесь другие мои работы](https://cryptodocguy.pro/).
