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

Все, що ви можете кешувати

рівень 2
кешування
сховище
Середнячок
Ori Pomerantz
15 вересня 2022 р.
21 читається за хвилину

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

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

Якщо ви хочете пропустити статтю і просто переглянути вихідний код, він тут (opens in a new tab). Стек розробки — Foundry (opens in a new tab).

Загальний дизайн

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

  1. Якщо перший байт — 0xFF, візьміть наступні 32 байти як значення параметра та запишіть його в кеш.

  2. Якщо перший байт — 0xFE, візьміть наступні 32 байти як значення параметра, але не записуйте його в кеш.

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

    Байти в calldataКлюч кешу
    0x0F0x0F
    0x10,0x100x10
    0x12,0xAC0x02AC
    0x2D,0xEA, 0xD60x0DEAD6

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

Кеш реалізовано в Cache.sol (opens in a new tab). Розгляньмо його рядок за рядком.

1// SPDX-License-Identifier: UNLICENSED
2pragma solidity ^0.8.13;
3
4
5contract Cache {
6
7 bytes1 public constant INTO_CACHE = 0xFF;
8 bytes1 public constant DONT_CACHE = 0xFE;

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

1
2 mapping(uint => uint) public val2key;

Відображення (opens in a new tab) між значеннями та їхніми ключами. Ця інформація необхідна для кодування значень перед відправкою транзакції.

1 // Позиція n має значення для ключа n+1, оскільки нам потрібно зберегти
2 // нуль як «не в кеші».
3 uint[] public key2val;

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

1 function cacheRead(uint _key) public view returns (uint) {
2 require(_key <= key2val.length, "Читання неініціалізованого запису кешу");
3 return key2val[_key-1];
4 } // cacheRead

Прочитати значення з кешу.

1 // Записати значення в кеш, якщо його там ще немає
2 // Лише public, щоб тест працював
3 function cacheWrite(uint _value) public returns (uint) {
4 // Якщо значення вже є в кеші, повертаємо поточний ключ
5 if (val2key[_value] != 0) {
6 return val2key[_value];
7 }

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

1 // Оскільки 0xFE є особливим випадком, найбільший ключ, який може містити кеш
2 // — це 0x0D, за яким слідують 15 0xFF. Якщо довжина кешу вже така
3 // велика, відбувається помилка.
4 // 1 2 3 4 5 6 7 8 9 A B C D E F
5 require(key2val.length+1 < 0x0DFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF,
6 "переповнення кешу");

Я не думаю, що ми колись отримаємо такий великий кеш (приблизно 1.8*1037 записів, для зберігання якого знадобилося б близько 1027 ТБ). Однак я достатньо дорослий, щоб пам’ятати «640 КБ завжди буде достатньо» (opens in a new tab). Цей тест дуже дешевий.

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

Додайте зворотний пошук (від значення до ключа).

1 key2val.push(_value);

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

1 return key2val.length;
2 } // cacheWrite

Повертає нову довжину key2val, яка є коміркою, де зберігається нове значення.

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

Ця функція зчитує значення з calldata довільної довжини (до 32 байтів, розмір слова).

1 {
2 uint _retVal;
3
4 require(length < 0x21,
5 "_calldataVal: обмеження довжини — 32 байти");
6 require(length + startByte <= msg.data.length,
7 "_calldataVal: спроба зчитування за межами calldatasize");

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

1 assembly {
2 _retVal := calldataload(startByte)
3 }

Цей код написаний на Yul (opens in a new tab). Він зчитує 32-байтове значення з calldata. Це працює, навіть якщо calldata закінчується перед startByte+32, оскільки неініціалізований простір в EVM вважається нульовим.

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

Нам не обов'язково потрібне 32-байтове значення. Це позбавляє від зайвих байтів.

1 return _retVal;
2 } // _calldataVal
3
4
5 // Зчитуємо один параметр із calldata, починаючи з _fromByte
6 function _readParam(uint _fromByte) internal
7 returns (uint _nextByte, uint _parameterValue)
8 {

Зчитування одного параметра з calldata. Зверніть увагу, що нам потрібно повернути не тільки зчитане значення, а й місцезнаходження наступного байта, оскільки параметри можуть мати довжину від 1 до 33 байтів.

1 // Перший байт вказує, як інтерпретувати решту
2 uint8 _firstByte;
3
4 _firstByte = uint8(_calldataVal(_fromByte, 1));

Solidity намагається зменшити кількість помилок, забороняючи потенційно небезпечні неявні перетворення типів (opens in a new tab). Зниження розрядності, наприклад з 256 біт до 8 біт, має бути явним.

1
2 // Зчитуємо значення, але не записуємо його в кеш
3 if (_firstByte == uint8(DONT_CACHE))
4 return(_fromByte+33, _calldataVal(_fromByte+1, 32));
5
6 // Зчитуємо значення і записуємо його в кеш
7 if (_firstByte == uint8(INTO_CACHE)) {
8 uint _param = _calldataVal(_fromByte+1, 32);
9 cacheWrite(_param);
10 return(_fromByte+33, _param);
11 }
12
13 // Якщо ми дійшли сюди, це означає, що нам потрібно зчитати з кешу
14
15 // Кількість додаткових байтів для зчитування
16 uint8 _extraBytes = _firstByte / 16;

Візьміть нижній нібл (opens in a new tab) і об'єднайте його з іншими байтами, щоб зчитати значення з кешу.

1 uint _key = (uint256(_firstByte & 0x0F) << (8*_extraBytes)) +
2 _calldataVal(_fromByte+1, _extraBytes);
3
4 return (_fromByte+_extraBytes+1, cacheRead(_key));
5
6 } // _readParam
7
8
9 // Зчитуємо n параметрів (функції знають, скільки параметрів вони очікують)
10 function _readParams(uint _paramNum) internal returns (uint[] memory) {

Ми могли б отримати кількість параметрів із самої calldata, але функції, що нас викликають, знають, скільки параметрів вони очікують. Простіше дозволити їм повідомити нам.

1 // Параметри, які ми зчитуємо
2 uint[] memory params = new uint[](_paramNum);
3
4 // Параметри починаються з 4-го байта, до цього йде підпис функції
5 uint _atByte = 4;
6
7 for(uint i=0; i<_paramNum; i++) {
8 (_atByte, params[i]) = _readParam(_atByte);
9 }

Зчитуйте параметри, доки не отримаєте потрібну кількість. Якщо ми вийдемо за межі calldata, _readParams скасує виклик.

1
2 return(params);
3 } // readParams
4
5 // Для тестування _readParams, тестуємо зчитування чотирьох параметрів
6 function fourParam() public
7 returns (uint256,uint256,uint256,uint256)
8 {
9 uint[] memory params;
10 params = _readParams(4);
11 return (params[0], params[1], params[2], params[3]);
12 } // fourParam

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

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

encodeVal — це функція, яку викликає офчейн-код, щоб допомогти створити calldata, яка використовує кеш. Вона отримує одне значення і повертає байти, які його кодують. Ця функція є view, тому вона не потребує транзакції і при зовнішньому виклику не коштує газу.

1 uint _key = val2key[_val];
2
3 // Значення ще немає в кеші, додаємо його
4 if (_key == 0)
5 return bytes.concat(INTO_CACHE, bytes32(_val));

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

1 // Якщо ключ <0x10, повертаємо його як один байт
2 if (_key < 0x10)
3 return bytes.concat(bytes1(uint8(_key)));

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

1 // Двобайтове значення, закодоване як 0x1vvv
2 if (_key < 0x1000)
3 return bytes.concat(bytes2(uint16(_key) | 0x1000));

Якщо ми маємо ключ менший за 163, ми можемо виразити його у двох байтах. Спочатку ми перетворюємо _key, яке є 256-бітним значенням, у 16-бітне значення та використовуємо логічне «АБО», щоб додати кількість додаткових байтів до першого байта. Потім ми просто перетворюємо його на значення bytes2, яке можна конвертувати в bytes.

1 // Ймовірно, існує хитрий спосіб зробити наступні рядки у вигляді циклу,
2 // але це функція view, тому я оптимізую для часу програміста та
3 // простоти.
4
5 if (_key < 16*256**2)
6 return bytes.concat(bytes3(uint24(_key) | (0x2 * 16 * 256**2)));
7 if (_key < 16*256**3)
8 return bytes.concat(bytes4(uint32(_key) | (0x3 * 16 * 256**3)));
9 .
10 .
11 .
12 if (_key < 16*256**14)
13 return bytes.concat(bytes15(uint120(_key) | (0xE * 16 * 256**14)));
14 if (_key < 16*256**15)
15 return bytes.concat(bytes16(uint128(_key) | (0xF * 16 * 256**15)));

Інші значення (3 байти, 4 байти тощо) обробляються так само, просто з різними розмірами полів.

1 // Якщо ми потрапили сюди, щось не так.
2 revert("Помилка в encodeVal, не повинно трапитися");

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

1 } // encodeVal
2
3} // Cache

Тестування кешу

Однією з переваг Foundry є те, що він дозволяє писати тести на Solidity (opens in a new tab), що полегшує написання юніт-тестів. Тести для класу Cache знаходяться тут (opens in a new tab). Оскільки код тестування є повторюваним, як це часто буває з тестами, у цій статті пояснюються лише цікаві частини.

1// SPDX-License-Identifier: UNLICENSED
2pragma solidity ^0.8.13;
3
4import "forge-std/Test.sol";
5
6
7// Потрібно запустити `forge test -vv` для консолі.
8import "forge-std/console.sol";

Це просто шаблонний код, необхідний для використання тестового пакета та console.log.

1import "src/Cache.sol";

Нам потрібно знати контракт, який ми тестуємо.

1contract CacheTest is Test {
2 Cache cache;
3
4 function setUp() public {
5 cache = new Cache();
6 }

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

1 function testCaching() public {

Тести — це функції, імена яких починаються з test. Ця функція перевіряє базову функціональність кешу, записуючи значення та зчитуючи їх знову.

1 for(uint i=1; i<5000; i++) {
2 cache.cacheWrite(i*i);
3 }
4
5 for(uint i=1; i<5000; i++) {
6 assertEq(cache.cacheRead(i), i*i);

Ось як ви виконуєте фактичне тестування, використовуючи функції assert... (opens in a new tab). У цьому випадку ми перевіряємо, що значення, яке ми записали, є тим, яке ми прочитали. Ми можемо відкинути результат cache.cacheWrite, оскільки знаємо, що ключі кешу призначаються лінійно.

1 }
2 } // testCaching
3
4
5 // Кешуємо одне й те саме значення кілька разів, переконуємося, що ключ залишається
6 // тим самим
7 function testRepeatCaching() public {
8 for(uint i=1; i<100; i++) {
9 uint _key1 = cache.cacheWrite(i);
10 uint _key2 = cache.cacheWrite(i);
11 assertEq(_key1, _key2);
12 }

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

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

Теоретично може існувати помилка, яка не впливає на послідовні записи в кеш. Тому тут ми робимо деякі записи, які не є послідовними, і бачимо, що значення все ще не перезаписуються.

1 // Зчитати uint з буфера пам’яті (щоб переконатися, що ми отримуємо назад параметри,
2 // які ми відправили)
3 function toUint256(bytes memory _bytes, uint256 _start) internal pure
4 returns (uint256)

Зчитування 256-бітного слова з буфера bytes memory. Ця утилітарна функція дозволяє нам перевірити, що ми отримуємо правильні результати, коли виконуємо виклик функції, яка використовує кеш.

1 {
2 require(_bytes.length >= _start + 32, "toUint256_вихід_за_межі");
3 uint256 tempUint;
4
5 assembly {
6 tempUint := mload(add(add(_bytes, 0x20), _start))
7 }

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

1
2 return tempUint;
3 } // toUint256
4
5 // Підпис функції для fourParams(), люб’язно наданий
6 // https://www.4byte.directory/signatures/?bytes4_signature=0x3edc1e6d
7 bytes4 constant FOUR_PARAMS = 0x3edc1e6d;
8
9 // Просто деякі постійні значення, щоб побачити, що ми отримуємо правильні значення назад
10 uint256 constant VAL_A = 0xDEAD60A7;
11 uint256 constant VAL_B = 0xBEEF;
12 uint256 constant VAL_C = 0x600D;
13 uint256 constant VAL_D = 0x600D60A7;

Деякі константи, які нам потрібні для тестування.

1 function testReadParam() public {

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

1 address _cacheAddr = address(cache);
2 bool _success;
3 bytes memory _callInput;
4 bytes memory _callOutput;

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

1 // Перший виклик, кеш порожній
2 _callInput = bytes.concat(
3 FOUR_PARAMS,

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

1 // Перше значення, додаємо його в кеш
2 cache.INTO_CACHE(),
3 bytes32(VAL_A),

Перше значення: прапорець, що вказує на те, що це повне значення, яке потрібно записати в кеш, за яким слідують 32 байти значення. Інші три значення схожі, за винятком того, що VAL_B не записується в кеш, а VAL_C є і третім, і четвертим параметром.

1 .
2 .
3 .
4 );
5 (_success, _callOutput) = _cacheAddr.call(_callInput);

Тут ми фактично викликаємо контракт Cache.

1 assertEq(_success, true);

Ми очікуємо, що виклик буде успішним.

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

Ми починаємо з порожнього кешу, а потім додаємо VAL_A, а за ним VAL_C. Ми очікуємо, що перший матиме ключ 1, а другий — 2.

1 assertEq(toUint256(_callOutput,0), VAL_A);
2 assertEq(toUint256(_callOutput,32), VAL_B);
3 assertEq(toUint256(_callOutput,64), VAL_C);
4 assertEq(toUint256(_callOutput,96), VAL_C);

На виході — чотири параметри. Тут ми перевіряємо, що він правильний.

1 // Другий виклик, ми можемо використовувати кеш
2 _callInput = bytes.concat(
3 FOUR_PARAMS,
4
5 // Перше значення в кеші
6 bytes1(0x01),

Ключі кешу менше 16 — це лише один байт.

1 // Друге значення, не додавати його до кешу
2 cache.DONT_CACHE(),
3 bytes32(VAL_B),
4
5 // Третє і четверте значення, те ж саме значення
6 bytes1(0x02),
7 bytes1(0x02)
8 );
9 .
10 .
11 .
12 } // testReadParam

Тести після виклику ідентичні тестам після першого виклику.

1 function testEncodeVal() public {

Ця функція схожа на testReadParam, за винятком того, що замість явного запису параметрів ми використовуємо encodeVal().

1 .
2 .
3 .
4 _callInput = bytes.concat(
5 FOUR_PARAMS,
6 cache.encodeVal(VAL_A),
7 cache.encodeVal(VAL_B),
8 cache.encodeVal(VAL_C),
9 cache.encodeVal(VAL_D)
10 );
11 .
12 .
13 .
14 assertEq(_callInput.length, 4+1*4);
15 } // testEncodeVal

Єдиний додатковий тест у testEncodeVal() — це перевірка правильності довжини _callInput. Для першого виклику вона становить 4+33_4. Для другого, де кожне значення вже є в кеші, вона становить 4+1_4.

1 // Тестування encodeVal, коли ключ складається з більш ніж одного байта
2 // Максимум три байти, тому що заповнення кешу до чотирьох байтів займає
3 // занадто багато часу.
4 function testEncodeValBig() public {
5 // Помістити кілька значень у кеш.
6 // Для простоти використовуйте ключ n для значення n.
7 for(uint i=1; i<0x1FFF; i++) {
8 cache.cacheWrite(i);
9 }

Функція testEncodeVal вище записує в кеш лише чотири значення, тому частина функції, яка працює з багатобайтовими значеннями (opens in a new tab) не перевіряється. Але цей код складний і схильний до помилок.

Перша частина цієї функції — це цикл, який записує в кеш усі значення від 1 до 0x1FFF по порядку, тому ми зможемо кодувати ці значення та знати, куди вони йдуть.

1 .
2 .
3 .
4
5 _callInput = bytes.concat(
6 FOUR_PARAMS,
7 cache.encodeVal(0x000F), // Один байт 0x0F
8 cache.encodeVal(0x0010), // Два байти 0x1010
9 cache.encodeVal(0x0100), // Два байти 0x1100
10 cache.encodeVal(0x1000) // Три байти 0x201000
11 );

Тестуємо одно-, дво- та трибайтові значення. Ми не тестуємо далі, тому що знадобиться занадто багато часу, щоб записати достатньо записів стека (принаймні 0x10000000, приблизно чверть мільярда).

1 .
2 .
3 .
4 .
5 } // testEncodeValBig
6
7
8 // Тестуємо, що з надмірно малим буфером ми отримуємо revert
9 function testShortCalldata() public {

Перевіряємо, що відбувається в аномальному випадку, коли параметрів недостатньо.

1 .
2 .
3 .
4 (_success, _callOutput) = _cacheAddr.call(_callInput);
5 assertEq(_success, false);
6 } // testShortCalldata

Оскільки відбувається скасування, результат, який ми повинні отримати, — false.

1 // Виклик із ключами кешу, яких немає
2 function testNoCacheKey() public {
3 .
4 .
5 .
6 _callInput = bytes.concat(
7 FOUR_PARAMS,
8
9 // Перше значення, додаємо його в кеш
10 cache.INTO_CACHE(),
11 bytes32(VAL_A),
12
13 // Друге значення
14 bytes1(0x0F),
15 bytes2(0x1234),
16 bytes11(0xA10102030405060708090A)
17 );

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

1 .
2 .
3 .
4 // Test what with an excessively long buffer everything works file
5 function testLongCalldata() public {
6 address _cacheAddr = address(cache);
7 bool _success;
8 bytes memory _callInput;
9 bytes memory _callOutput;
10
11 // First call, the cache is empty
12 _callInput = bytes.concat(
13 FOUR_PARAMS,
14
15 // First value, add it to the cache
16 cache.INTO_CACHE(), bytes32(VAL_A),
17
18 // Second value, add it to the cache
19 cache.INTO_CACHE(), bytes32(VAL_B),
20
21 // Third value, add it to the cache
22 cache.INTO_CACHE(), bytes32(VAL_C),
23
24 // Fourth value, add it to the cache
25 cache.INTO_CACHE(), bytes32(VAL_D),
26
27 // And another value for "good luck"
28 bytes4(0x31112233)
29 );

Ця функція надсилає п’ять значень. Ми знаємо, що п'яте значення ігнорується, тому що це недійсний запис кешу, що спричинило б скасування, якби він не був включений.

1 (_success, _callOutput) = _cacheAddr.call(_callInput);
2 assertEq(_success, true);
3 .
4 .
5 .
6 } // testLongCalldata
7
8} // CacheTest
9

Зразок застосунку

Писати тести на Solidity — це, звісно, добре, але, зрештою, для того, щоб dapp був корисним, він має вміти обробляти запити з-поза меж ланцюга. У цій статті показано, як використовувати кешування в dapp з WORM, що означає «Write Once, Read Many» (один раз записати, багато разів прочитати). Якщо ключ ще не записаний, ви можете записати в нього значення. Якщо ключ уже записаний, ви отримаєте скасування.

Контракт

Це контракт (opens in a new tab). Він здебільшого повторює те, що ми вже робили з Cache і CacheTest, тому ми розглянемо лише цікаві моменти.

1import "./Cache.sol";
2
3contract WORM is Cache {

Найпростіший спосіб використовувати Cache — це успадкувати його у власному контракті.

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

Ця функція схожа на fourParam у CacheTest вище. Оскільки ми не дотримуємося специфікацій ABI, краще не оголошувати жодних параметрів у функції.

1 // Make it easier to call us
2 // Function signature for writeEntryCached(), courtesy of
3 // https://www.4byte.directory/signatures/?bytes4_signature=0xe4e4f2d3
4 bytes4 constant public WRITE_ENTRY_CACHED = 0xe4e4f2d3;

Зовнішній код, який викликає writeEntryCached, повинен буде вручну створювати calldata, замість використання worm.writeEntryCached, оскільки ми не дотримуємося специфікацій ABI. Наявність цієї константи просто полегшує її написання.

Зверніть увагу, що хоча ми визначаємо WRITE_ENTRY_CACHED як змінну стану, для її зовнішнього зчитування необхідно використовувати функцію-геттер для неї, worm.WRITE_ENTRY_CACHED().

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

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

Тестовий код

Це код для тестування контракту (opens in a new tab). Знову ж таки, розглянемо лише те, що є цікавим.

1 function testWReadWrite() public {
2 worm.writeEntry(0xDEAD, 0x60A7);
3
4 vm.expectRevert(bytes("entry already written"));
5 worm.writeEntry(0xDEAD, 0xBEEF);

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

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

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

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

Ось як ми повідомляємо Solidity, що, хоча <contract address>.call() має два значення, що повертаються, нас цікавить лише перше.

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

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

1 event EntryWritten(uint indexed key, uint indexed value);
2
3 .
4 .
5 .
6
7 _callInput = bytes.concat(
8 worm.WRITE_ENTRY_CACHED(), worm.encodeVal(a), worm.encodeVal(b));
9 vm.expectEmit(true, true, false, false);
10 emit EntryWritten(a, b);
11 (_success,) = address(worm).call(_callInput);

Це спосіб, яким ми перевіряємо, що код правильно генерує подію (opens in a new tab) в Foundry.

Клієнт

Одна річ, якої ви не отримаєте з тестами Solidity, — це код JavaScript, який можна скопіювати та вставити у власний додаток. Щоб написати цей код, я розгорнув WORM на Optimism Goerli (opens in a new tab), новій тестовій мережі Optimism (opens in a new tab). Його адреса 0xd34335b1d818cee54e3323d3246bd31d94e6a78a (opens in a new tab).

Код JavaScript для клієнта можна переглянути тут (opens in a new tab). Щоб ним скористатися:

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

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

    1cd javascript
    2yarn
  3. Скопіюйте файл конфігурації:

    1cp .env.example .env
  4. Відредагуйте .env для вашої конфігурації:

    ПараметрЗначення
    MNEMONICМнемонічна фраза для облікового запису, на якому є достатньо ETH для оплати транзакції. Тут ви можете отримати безкоштовний ETH для мережі Optimism Goerli (opens in a new tab).
    OPTIMISM_GOERLI_URLURL до Optimism Goerli. Публічна кінцева точка, https://goerli.optimism.io, має обмеження по швидкості, але достатня для наших потреб тут
  5. Запустіть index.js.

    1node index.js

    Цей зразок застосунку спочатку записує запис у WORM, відображаючи calldata та посилання на транзакцію на Etherscan. Потім він зчитує цей запис і відображає ключ, який він використовує, і значення в записі (значення, номер блоку та автора).

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

1.
2.
3.
4const main = async () => {
5 const func = await worm.WRITE_ENTRY_CACHED()
6
7 // Need a new key every time
8 const key = await worm.encodeVal(Number(new Date()))

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

1const val = await worm.encodeVal("0x600D")
2
3// Write an entry
4const calldata = func + key.slice(2) + val.slice(2)

Ethers очікує, що дані виклику будуть шістнадцятковим рядком, 0x, за яким слідує парна кількість шістнадцяткових цифр. Оскільки і key, і val починаються з 0x, нам потрібно видалити ці заголовки.

1const tx = await worm.populateTransaction.writeEntryCached()
2tx.data = calldata
3
4sentTx = await wallet.sendTransaction(tx)

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

1 .
2 .
3 .
4 // Read the entry just written
5 const realKey = '0x' + key.slice(4) // remove the FF flag
6 const entryRead = await worm.readEntry(realKey)
7 .
8 .
9 .

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

Висновок

Код у цій статті є доказом концепції, мета якої — зробити ідею легкою для розуміння. Для системи, готової до виробництва, ви можете реалізувати деякі додаткові функції:

  • Обробка значень, які не є uint256. Наприклад, рядки.

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

  • Значення, що використовуються для адрес, відрізняються від тих, що використовуються для інших цілей. Можливо, має сенс мати окремий кеш лише для адрес.

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

    Однак це потенційно небезпечна операція. Уявіть собі таку послідовність подій:

    1. Ноам Наївний викликає encodeVal для кодування адреси, на яку він хоче надіслати токени. Ця адреса є однією з перших, що використовуються в додатку, тому закодоване значення — 0x06. Це функція view, а не транзакція, тому це відбувається між Ноамом і вузлом, який він використовує, і ніхто інший про це не знає

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

    3. Ноам Наївний надсилає свої токени на 0x06. Вони потрапляють на адресу 0x0000000000000000000000000de0b6b3a7640000, і оскільки ніхто не знає приватного ключа від цієї адреси, вони просто там застрягають. Ноам не в захваті.

    Існують способи вирішення цієї проблеми та пов’язаної з нею проблеми транзакцій, які перебувають у мемпулі під час реорганізації кешу, але ви повинні про це знати.

Я продемонстрував кешування тут з Optimism, тому що я співробітник Optimism, і це той rollup, який я знаю найкраще. Але це має працювати з будь-яким rollup, який стягує мінімальну плату за внутрішню обробку, так що, порівняно, запис даних транзакції в L1 є основною витратою.

Більше моїх робіт дивіться тут (opens in a new tab).

Останні оновлення сторінки: 3 березня 2026 р.

Чи була ця інструкція корисною?