Все, що ви можете кешувати
Під час використання зведень вартість байта в транзакції набагато дорожча за вартість слоту зберігання. Тому є сенс кешувати якомога більше інформації ончейн.
У цій статті ви дізнаєтеся, як створити та використовувати контракт кешування таким чином, щоб будь-яке значення параметра, яке, ймовірно, буде використовуватися кілька разів, кешувалося та було доступним для використання (після першого разу) з набагато меншою кількістю байтів, і як написати офчейн-код, який використовує цей кеш.
Якщо ви хочете пропустити статтю і просто переглянути вихідний код, він тут (opens in a new tab). Стек розробки — Foundry (opens in a new tab).
Загальний дизайн
Для простоти припустимо, що всі параметри транзакції мають тип uint256 і довжину 32 байти. Коли ми отримуємо транзакцію, ми аналізуємо кожен параметр таким чином:
-
Якщо перший байт —
0xFF, візьміть наступні 32 байти як значення параметра та запишіть його в кеш. -
Якщо перший байт —
0xFE, візьміть наступні 32 байти як значення параметра, але не записуйте його в кеш. -
Для будь-якого іншого значення візьміть верхні чотири біти як кількість додаткових байтів, а нижні чотири біти — як найстарші біти ключа кешу. Ось кілька прикладів:
Байти в calldata Ключ кешу 0x0F 0x0F 0x10,0x10 0x10 0x12,0xAC 0x02AC 0x2D,0xEA, 0xD6 0x0DEAD6
Маніпуляції з кешем
Кеш реалізовано в Cache.sol (opens in a new tab). Розгляньмо його рядок за рядком.
1// SPDX-License-Identifier: UNLICENSED2pragma 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 F5 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 } // _calldataVal3
4
5 // Зчитуємо один параметр із calldata, починаючи з _fromByte6 function _readParam(uint _fromByte) internal7 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 } // _readParam7
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 } // readParams4
5 // Для тестування _readParams, тестуємо зчитування чотирьох параметрів6 function fourParam() public7 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 // Двобайтове значення, закодоване як 0x1vvv2 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 } // encodeVal2
3} // CacheТестування кешу
Однією з переваг Foundry є те, що він дозволяє писати тести на Solidity (opens in a new tab), що полегшує написання юніт-тестів. Тести для класу Cache знаходяться тут (opens in a new tab). Оскільки код тестування є повторюваним, як це часто буває з тестами, у цій статті пояснюються лише цікаві частини.
1// SPDX-License-Identifier: UNLICENSED2pragma 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 } // testCaching3
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 pure4 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 } // toUint2564
5 // Підпис функції для fourParams(), люб’язно наданий6 // https://www.4byte.directory/signatures/?bytes4_signature=0x3edc1e6d7 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), // Один байт 0x0F8 cache.encodeVal(0x0010), // Два байти 0x10109 cache.encodeVal(0x0100), // Два байти 0x110010 cache.encodeVal(0x1000) // Три байти 0x20100011 );Тестуємо одно-, дво- та трибайтові значення. Ми не тестуємо далі, тому що знадобиться занадто багато часу, щоб записати достатньо записів стека (принаймні 0x10000000, приблизно чверть мільярда).
1 .2 .3 .4 .5 } // testEncodeValBig6
7
8 // Тестуємо, що з надмірно малим буфером ми отримуємо revert9 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 file5 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 empty12 _callInput = bytes.concat(13 FOUR_PARAMS,14
15 // First value, add it to the cache16 cache.INTO_CACHE(), bytes32(VAL_A),17
18 // Second value, add it to the cache19 cache.INTO_CACHE(), bytes32(VAL_B),20
21 // Third value, add it to the cache22 cache.INTO_CACHE(), bytes32(VAL_C),23
24 // Fourth value, add it to the cache25 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 } // testLongCalldata7
8} // CacheTest9
Зразок застосунку
Писати тести на 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 us2 // Function signature for writeEntryCached(), courtesy of3 // https://www.4byte.directory/signatures/?bytes4_signature=0xe4e4f2d34 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 view2 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). Щоб ним скористатися:
-
Клонуйте репозиторій git:
1git clone https://github.com/qbzzt/20220915-all-you-can-cache.git -
Встановіть необхідні пакети:
1cd javascript2yarn -
Скопіюйте файл конфігурації:
1cp .env.example .env -
Відредагуйте
.envдля вашої конфігурації:Параметр Значення MNEMONIC Мнемонічна фраза для облікового запису, на якому є достатньо ETH для оплати транзакції. Тут ви можете отримати безкоштовний ETH для мережі Optimism Goerli (opens in a new tab). OPTIMISM_GOERLI_URL URL до Optimism Goerli. Публічна кінцева точка, https://goerli.optimism.io, має обмеження по швидкості, але достатня для наших потреб тут -
Запустіть
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 time8 const key = await worm.encodeVal(Number(new Date()))У даний слот можна записати лише один раз, тому ми використовуємо мітку часу, щоб переконатися, що не повторюємо слоти.
1const val = await worm.encodeVal("0x600D")2
3// Write an entry4const calldata = func + key.slice(2) + val.slice(2)Ethers очікує, що дані виклику будуть шістнадцятковим рядком, 0x, за яким слідує парна кількість шістнадцяткових цифр. Оскільки і key, і val починаються з 0x, нам потрібно видалити ці заголовки.
1const tx = await worm.populateTransaction.writeEntryCached()2tx.data = calldata3
4sentTx = await wallet.sendTransaction(tx)Як і в коді тестування Solidity, ми не можемо викликати кешовану функцію звичайним способом. Натомість нам потрібно використовувати механізм нижчого рівня.
1 .2 .3 .4 // Read the entry just written5 const realKey = '0x' + key.slice(4) // remove the FF flag6 const entryRead = await worm.readEntry(realKey)7 .8 .9 .Для зчитування записів ми можемо використовувати звичайний механізм. Немає потреби використовувати кешування параметрів з функціями view.
Висновок
Код у цій статті є доказом концепції, мета якої — зробити ідею легкою для розуміння. Для системи, готової до виробництва, ви можете реалізувати деякі додаткові функції:
-
Обробка значень, які не є
uint256. Наприклад, рядки. -
Замість глобального кешу, можливо, мати відображення між користувачами та кешами. Різні користувачі використовують різні значення.
-
Значення, що використовуються для адрес, відрізняються від тих, що використовуються для інших цілей. Можливо, має сенс мати окремий кеш лише для адрес.
-
Наразі ключі кешу працюють за алгоритмом «хто перший прийшов, той отримав найменший ключ». Перші шістнадцять значень можна надіслати як один байт. Наступні 4080 значень можна надіслати як два байти. Наступні приблизно мільйон значень — три байти і т. д. Виробнича система повинна вести лічильники використання записів кешу та реорганізовувати їх так, щоб шістнадцять найпоширеніших значень займали один байт, наступні 4080 найпоширеніших значень — два байти і т. д.
Однак це потенційно небезпечна операція. Уявіть собі таку послідовність подій:
-
Ноам Наївний викликає
encodeValдля кодування адреси, на яку він хоче надіслати токени. Ця адреса є однією з перших, що використовуються в додатку, тому закодоване значення — 0x06. Це функціяview, а не транзакція, тому це відбувається між Ноамом і вузлом, який він використовує, і ніхто інший про це не знає -
Оуен Власник запускає операцію реорганізації кешу. Дуже мало людей насправді використовують цю адресу, тому вона тепер закодована як 0x201122. Іншому значенню, 1018, присвоєно 0x06.
-
Ноам Наївний надсилає свої токени на 0x06. Вони потрапляють на адресу
0x0000000000000000000000000de0b6b3a7640000, і оскільки ніхто не знає приватного ключа від цієї адреси, вони просто там застрягають. Ноам не в захваті.
Існують способи вирішення цієї проблеми та пов’язаної з нею проблеми транзакцій, які перебувають у мемпулі під час реорганізації кешу, але ви повинні про це знати.
-
Я продемонстрував кешування тут з Optimism, тому що я співробітник Optimism, і це той rollup, який я знаю найкраще. Але це має працювати з будь-яким rollup, який стягує мінімальну плату за внутрішню обробку, так що, порівняно, запис даних транзакції в L1 є основною витратою.
Більше моїх робіт дивіться тут (opens in a new tab).
Останні оновлення сторінки: 3 березня 2026 р.