Перейти к основному контенту

Всё, что можно кэшировать

уровень 2
кэширование
хранение
масштабирование
Средний уровень
Ори Померанц
15 сентября 2022 г.
21 минут на чтение

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

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

Если вы хотите пропустить статью и просто посмотреть исходный код, он находится здесь (opens in a new tab). Стек разработки — Foundry (opens in a new tab).

Общая архитектура

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

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

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

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

    Байты в данных вызоваКлюч кэша
    0x0F0x0F
    0x10,0x100x10
    0x12,0xAC0x02AC
    0x2D,0xEA, 0xD60x0DEAD6

Управление кэшем

Кэш реализован в Cache.sol (opens in a new tab). Давайте разберем его построчно.

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


contract Cache {

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

Эти константы используются для интерпретации особых случаев, когда мы предоставляем всю информацию и хотим, чтобы она была записана в кэш или нет. Запись в кэш требует двух операций SSTORE (opens in a new tab) в ранее неиспользованные слоты хранения стоимостью 22100 газа каждая, поэтому мы делаем ее необязательной.


    mapping(uint => uint) public val2key;

Отображение (mapping) (opens in a new tab) между значениями и их ключами. Эта информация необходима для кодирования значений перед отправкой транзакции.

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

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

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

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

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

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

        // Поскольку 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*1037 записей, для хранения которых потребуется около 1027 ТБ). Однако я достаточно стар, чтобы помнить фразу «640 КБ хватит всем» (opens in a new tab). Эта проверка обходится очень дешево.

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

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

        key2val.push(_value);

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

        return key2val.length;
    }  // cacheWrite

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

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

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

    {
        uint _retVal;

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

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

        assembly {
            _retVal := calldataload(startByte)
        }

Этот код написан на Yul (opens in a new tab). Он считывает 32-байтовое значение из данных вызова. Это работает, даже если данные вызова заканчиваются до startByte+32, потому что неинициализированное пространство в EVM считается равным нулю.

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

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

        return _retVal;
    } // _calldataVal


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

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

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

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

Solidity пытается уменьшить количество ошибок, запрещая потенциально опасные неявные преобразования типов (opens in a new tab). Понижение типа, например, с 256 бит до 8 бит, должно быть явным.

Берем младший полубайт (nibble) (opens in a new tab) и объединяем его с остальными байтами для чтения значения из кэша.

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

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

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

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

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

        uint _key = val2key[_val];

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

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

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

Одиночные байты — самые простые. Мы просто используем bytes.concat (opens in a new tab), чтобы превратить тип bytes<n> в массив байтов, который может быть любой длины. Несмотря на название, он отлично работает, когда предоставляется только один аргумент.

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

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

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

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

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

    } // encodeVal

}  // Cache

Тестирование кэша

Одно из преимуществ Foundry заключается в том, что он позволяет писать тесты на Solidity (opens in a new tab), что упрощает написание модульных тестов. Тесты для класса Cache находятся здесь (opens in a new tab). Поскольку код тестирования повторяется, как это обычно бывает с тестами, в этой статье объясняются только самые интересные части.

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

import "forge-std/Test.sol";


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

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

import "src/Cache.sol";

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

contract CacheTest is Test {
    Cache cache;

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

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

    function testCaching() public {

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

        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... (opens in a new tab). В данном случае мы проверяем, что записанное нами значение совпадает с прочитанным. Мы можем отбросить результат cache.cacheWrite, потому что знаем, что ключи кэша назначаются линейно.

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

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

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

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

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

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

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

    function testReadParam() public {

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

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

Мы не можем использовать обычный механизм ABI для вызова функции с использованием кэша, поэтому нам нужно использовать низкоуровневый механизм <address>.call() (opens in a new tab). Этот механизм принимает bytes memory в качестве входных данных и возвращает его же (а также логическое значение) в качестве выходных данных.

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

Полезно, чтобы один и тот же контракт поддерживал как кэшированные функции (для вызовов непосредственно из транзакций), так и некэшированные функции (для вызовов из других смарт-контрактов). Для этого нам нужно продолжать полагаться на механизм Solidity для вызова правильной функции, вместо того чтобы помещать все в функцию fallback (opens in a new tab). Это значительно упрощает композируемость. В большинстве случаев для идентификации функции было бы достаточно одного байта, поэтому мы тратим впустую три байта (16*3=48 газа). Однако на момент написания этой статьи эти 48 единиц газа стоят 0,07 цента, что является разумной платой за более простой и менее подверженный ошибкам код.

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

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

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

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

        assertEq(_success, true);

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

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

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

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

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

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

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

    function testEncodeVal() public {

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

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

Функция testEncodeVal выше записывает в кэш только четыре значения, поэтому часть функции, которая работает с многобайтовыми значениями (opens in a new tab), не проверяется. Но этот код сложен и подвержен ошибкам.

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

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

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

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

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

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

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

Пример приложения

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

Контракт

Вот этот контракт (opens in a new tab). Он в основном повторяет то, что мы уже сделали с Cache и CacheTest, поэтому мы рассмотрим только интересные части.

import "./Cache.sol";

contract WORM is Cache {

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

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

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

    // Сделать так, чтобы нас было проще вызвать
    // Сигнатура функции для 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().

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

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

Код тестирования

Это код тестирования для контракта (opens in a new tab). Опять же, давайте посмотрим только на то, что интересно.

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

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

Вот так (vm.expectRevert) (opens in a new tab) мы указываем в тесте Foundry, что следующий вызов должен завершиться ошибкой, и сообщаем причину сбоя. Это применимо, когда мы используем синтаксис <contract>.<function name>(), а не создаем данные вызова и не вызываем контракт с использованием низкоуровневого интерфейса (<contract>.call() и т. д.).

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

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

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

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

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

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

Таким образом мы проверяем, что код правильно генерирует событие (opens in a new tab) в Foundry.

Клиент

Чего вы не получите с тестами на Solidity, так это кода на JavaScript, который можно скопировать и вставить в собственное приложение. В оригинальной версии этого руководства контракт WORM развертывался в сети Optimism Гёрли, которая с тех пор была выведена из эксплуатации. Чтобы запустить клиент сегодня, повторно разверните WORM в поддерживаемой сети OP Stack, такой как OP Sepolia (opens in a new tab), а затем используйте полученный адрес контракта в клиенте на JavaScript.

Код клиента на JavaScript можно посмотреть здесь (opens in a new tab). Пример репозитория был написан для Optimism Гёрли, поэтому перед его запуском обновите URL-адреса конечной точки RPC и обозревателя в файлах javascript/.env.example и javascript/index.js для вашей целевой сети. Чтобы использовать его:

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

    git clone https://github.com/qbzzt/20220915-all-you-can-cache.git
    
  2. Установите необходимые пакеты:

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

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

    ПараметрЗначение
    MNEMONICМнемоническая фраза для аккаунта, на котором достаточно ETH для оплаты транзакции. В документации по кранам Optimism (opens in a new tab) перечислены текущие краны тестовой сети.
    OPTIMISM_GOERLI_URLURL-адрес RPC для сети, в которой вы повторно развертываете WORM. Для OP Sepolia используйте конечную точку RPC OP Sepolia, например https://sepolia.optimism.io, или другую конечную точку от вашего провайдера.
  5. Запустите index.js.

    node index.js
    

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

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

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

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

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

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

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

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

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

sentTx = await wallet.sendTransaction(tx)

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

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

Заключение

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

  • Обработка значений, которые не являются uint256. Например, строк.

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

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

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

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

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

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

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

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

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

Смотрите здесь другие мои работы (opens in a new tab).