Перейти к основному содержанию

Что можно кешировать

уровень 2
кеширование
хранилище
Intermediate
Ori Pomerantz
15 сентября 2022 г.
21 минута прочтения

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

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

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

Общий дизайн

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

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

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

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

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

Манипулирование кешем

Кеш реализован в Cache.solopens 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;

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

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

Сопоставление (mapping)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 length limit is 32 bytes");
6 require(length + startByte <= msg.data.length,
7 "_calldataVal trying to read beyond calldatasize");

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

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

Этот код написан на Yulopens 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.concatopens 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 заключается в том, что оно позволяет писать тесты на Solidityopens 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` для console.
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_outOfBounds");
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 для вызова правильной функции, вместо того чтобы помещать все в функцию fallbackopens 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+334. Для второго, где каждое значение уже находится в кеше, это 4+14.

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 // Тестируем, что при слишком маленьком буфере мы получаем отмену
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 // Тестируем, что при слишком большом буфере все работает
5 function testLongCalldata() public {
6 address _cacheAddr = address(cache);
7 bool _success;
8 bytes memory _callInput;
9 bytes memory _callOutput;
10
11 // Первый вызов, кеш пуст
12 _callInput = bytes.concat(
13 FOUR_PARAMS,
14
15 // Первое значение, добавить его в кеш
16 cache.INTO_CACHE(), bytes32(VAL_A),
17
18 // Второе значение, добавить его в кеш
19 cache.INTO_CACHE(), bytes32(VAL_B),
20
21 // Третье значение, добавить его в кеш
22 cache.INTO_CACHE(), bytes32(VAL_C),
23
24 // Четвертое значение, добавить его в кеш
25 cache.INTO_CACHE(), bytes32(VAL_D),
26
27 // И еще одно значение для «удачи»
28 bytes4(0x31112233)
29 );
Показать все

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

1 (_success, _callOutput) = _cacheAddr.call(_callInput);
2 assertEq(_success, true);
3 .
4 .
5 .
6 } // testLongCalldata
7
8} // CacheTest
9
Показать все

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

Писать тесты на Solidity — это, конечно, хорошо, но в конечном счете, чтобы быть полезным, децентрализованное приложение должно уметь обрабатывать запросы извне сети. В этой статье показано, как использовать кеширование в децентрализованном приложении с помощью 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 // Упростить вызов
2 // Сигнатура функции для writeEntryCached() взята с
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 Goerliopens in a new tab, новой тестовой сети Optimismopens in a new tab. Он находится по адресу 0xd34335b1d818cee54e3323d3246bd31d94e6a78aopens 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. Затем оно считывает эту запись и отображает используемый ключ и значения в записи (значение, номер блока и автор).

Большая часть клиента — это обычный JavaScript для децентрализованных приложений. Так что, опять же, мы рассмотрим только интересные части.

1.
2.
3.
4const main = async () => {
5 const func = await worm.WRITE_ENTRY_CACHED()
6
7 // Каждый раз нужен новый ключ
8 const key = await worm.encodeVal(Number(new Date()))

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

1const val = await worm.encodeVal("0x600D")
2
3// Записать запись
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 // Прочитать только что сделанную запись
5 const realKey = '0x' + key.slice(4) // удалить флаг FF
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, и это ролл-ап, который я знаю лучше всего. Но это должно работать с любым ролл-апом, который взимает минимальную плату за внутреннюю обработку, так что по сравнению с этим запись данных транзакции в L1 является основной статьей расходов.

Больше моих работ смотрите здесьopens in a new tab.

Последнее обновление страницы: 25 февраля 2026 г.

Было ли это руководство полезным?