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

Під час використання ролапів вартість байта в транзакції набагато вища, ніж вартість слота сховища. Тому має сенс кешувати якомога більше інформації ончейн.

У цій статті ви дізнаєтеся, як створити та використовувати контракт кешування таким чином, щоб будь-яке значення параметра, яке, ймовірно, буде використовуватися кілька разів, кешувалося та було доступним для використання (після першого разу) з набагато меншою кількістю байтів, а також як написати позамережевий код, який використовує цей кеш.

Якщо ви хочете пропустити статтю і просто переглянути вихідний код, [він тут](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}

Кеш реалізовано в [`Кеш.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
    // Записати значення в кеш, якщо його там ще немає
    // Публічний лише для того, щоб тест міг працювати
    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;
```

Беремо нижній [нібл (напівбайт)](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`.

```
// Call with cache keys that aren't there
    function testNoCacheKey() public {
        .
        .
        .
        _callInput = bytes.concat(
            FOUR_PARAMS,

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

            // Second value
            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,

            // First value, add it to the cache
            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 Ґерлі, тому перед його запуском оновіть кінцеву точку RPC та URL-адреси оглядача в `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, відображаючи дані виклику та посилання на транзакцію в оглядачі блоків. Потім він зчитує цей запис і відображає ключ, який він використовує, та значення в записі (значення, номер блоку та автора).

Більша частина клієнта — це звичайний JavaScript для децентралізованого застосунку (dapp). Тому ми знову розглянемо лише найцікавіші частини.

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