Перейти до основного вмісту

Кешуйте все, що можете

рівень 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

Маніпуляції з кешем

Кеш реалізовано в Кеш.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

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

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

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

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

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