Что можно кешировать
При использовании ролл-апов стоимость байта в транзакции намного выше, чем стоимость слота в хранилище. Поэтому имеет смысл кешировать ончейн как можно больше информации.
В этой статье вы узнаете, как создавать и использовать кеширующий контракт таким образом, чтобы любое значение параметра, которое, скорее всего, будет использоваться несколько раз, кешировалось и было доступно для использования (после первого раза) с гораздо меньшим количеством байтов, и как писать оффчейн-код, использующий этот кеш.
Если вы хотите пропустить статью и просто посмотреть исходный код, он находится здесьopens in a new tab. Стек разработки — Foundryopens in a new tab.
Общий дизайн
Для простоты предположим, что все параметры транзакции имеют тип uint256 и длину 32 байта. Когда мы получаем транзакцию, мы разбираем каждый параметр следующим образом:
-
Если первый байт равен
0xFF, взять следующие 32 байта как значение параметра и записать его в кеш. -
Если первый байт равен
0xFE, взять следующие 32 байта как значение параметра, но не записывать его в кеш. -
Для любого другого значения взять старшие четыре бита как количество дополнительных байтов, а младшие четыре бита — как старшие значащие биты ключа кеша. Вот несколько примеров:
Байты в calldata Ключ кеша 0x0F 0x0F 0x10,0x10 0x10 0x12,0xAC 0x02AC 0x2D,0xEA, 0xD6 0x0DEAD6
Манипулирование кешем
Кеш реализован в Cache.solopens in a new tab. Давайте рассмотрим его построчно.
1// SPDX-License-Identifier: UNLICENSED2pragma solidity ^0.8.13;345contract Cache {67 bytes1 public constant INTO_CACHE = 0xFF;8 bytes1 public constant DONT_CACHE = 0xFE;Эти константы используются для интерпретации особых случаев, когда мы предоставляем всю информацию и решаем, записывать ее в кеш или нет. Запись в кеш требует двух операций SSTOREopens in a new tab в ранее неиспользованные слоты хранилища стоимостью 22100 ед. газа каждая, поэтому мы делаем ее необязательной.
12 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 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;34 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 } // _calldataVal345 // Считать один параметр из calldata, начиная с _fromByte6 function _readParam(uint _fromByte) internal7 returns (uint _nextByte, uint _parameterValue)8 {Считывание одного параметра из calldata. Обратите внимание, что нам нужно вернуть не только прочитанное значение, но и местоположение следующего байта, потому что параметры могут иметь длину от 1 до 33 байт.
1 // Первый байт говорит нам, как интерпретировать остальные2 uint8 _firstByte;34 _firstByte = uint8(_calldataVal(_fromByte, 1));Solidity пытается уменьшить количество ошибок, запрещая потенциально опасные неявные преобразования типовopens in a new tab. Понижение, например с 256 бит до 8 бит, должно быть явным.
12 // Прочитать значение, но не записывать его в кеш3 if (_firstByte == uint8(DONT_CACHE))4 return(_fromByte+33, _calldataVal(_fromByte+1, 32));56 // Прочитать значение и записать его в кеш7 if (_firstByte == uint8(INTO_CACHE)) {8 uint _param = _calldataVal(_fromByte+1, 32);9 cacheWrite(_param);10 return(_fromByte+33, _param);11 }1213 // Если мы дошли до этого места, это означает, что нам нужно читать из кеша1415 // Количество дополнительных байтов для чтения16 uint8 _extraBytes = _firstByte / 16;Показать всеВозьмите младший нибблopens in a new tab и объедините его с другими байтами, чтобы прочитать значение из кеша.
1 uint _key = (uint256(_firstByte & 0x0F) << (8*_extraBytes)) +2 _calldataVal(_fromByte+1, _extraBytes);34 return (_fromByte+_extraBytes+1, cacheRead(_key));56 } // _readParam789 // Прочитать n параметров (функции знают, сколько параметров они ожидают)10 function _readParams(uint _paramNum) internal returns (uint[] memory) {Показать всеМы могли бы получить количество имеющихся у нас параметров из самой calldata, но функции, которые нас вызывают, знают, сколько параметров они ожидают. Проще позволить им сообщить нам.
1 // Параметры, которые мы читаем2 uint[] memory params = new uint[](_paramNum);34 // Параметры начинаются с 4-го байта, до этого идет сигнатура функции5 uint _atByte = 4;67 for(uint i=0; i<_paramNum; i++) {8 (_atByte, params[i]) = _readParam(_atByte);9 }Показать всеСчитывайте параметры, пока не получите необходимое их количество. Если мы выйдем за конец calldata, _readParams отменит вызов.
12 return(params);3 } // readParams45 // Для тестирования _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];23 // Значения еще нет в кеше, добавляем его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 // Двухбайтовое значение, закодированное как 0x1vvv2 if (_key < 0x1000)3 return bytes.concat(bytes2(uint16(_key) | 0x1000));Когда у нас есть ключ меньше 163, мы можем выразить его в двух байтах. Сначала мы преобразуем _key, которое является 256-битным значением, в 16-битное значение и используем логическое «ИЛИ», чтобы добавить количество дополнительных байтов к первому байту. Затем мы просто преобразуем его в значение bytes2, которое может быть конвертировано в bytes.
1 // Вероятно, есть более изящный способ сделать следующие строки в виде цикла,2 // но это функция view, поэтому я оптимизирую ее с точки зрения времени программиста и3 // простоты.45 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 } // encodeVal23} // CacheТестирование кеша
Одно из преимуществ Foundry заключается в том, что оно позволяет писать тесты на Solidityopens in a new tab, что упрощает написание модульных тестов. Тесты для класса Cache находятся здесьopens in a new tab. Поскольку код для тестов повторяется, что характерно для тестов, в этой статье объясняются только интересные части.
1// SPDX-License-Identifier: UNLICENSED2pragma solidity ^0.8.13;34import "forge-std/Test.sol";567// Необходимо запустить `forge test -vv` для console.8import "forge-std/console.sol";Это просто шаблонный код, необходимый для использования тестового пакета и console.log.
1import "src/Cache.sol";Нам нужно знать контракт, который мы тестируем.
1contract CacheTest is Test {2 Cache cache;34 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 }45 for(uint i=1; i<5000; i++) {6 assertEq(cache.cacheRead(i), i*i);Вот как вы проводите фактическое тестирование, используя функции assert...opens in a new tab. В этом случае мы проверяем, что значение, которое мы записали, является тем, которое мы прочитали. Мы можем отбросить результат cache.cacheWrite, потому что знаем, что ключи кеша назначаются линейно.
1 }2 } // testCaching345 // Кешировать одно и то же значение несколько раз, убедиться, что ключ остается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_outOfBounds");3 uint256 tempUint;45 assembly {6 tempUint := mload(add(add(_bytes, 0x20), _start))7 }Yul не поддерживает структуры данных сложнее uint256, поэтому, когда вы обращаетесь к более сложной структуре данных, такой как буфер памяти _bytes, вы получаете адрес этой структуры. Solidity хранит значения bytes memory в виде 32-байтового слова, которое содержит длину, за которой следуют фактические байты, так что для того чтобы получить байт номер _start, нам нужно вычислить _bytes+32+_start.
12 return tempUint;3 } // toUint25645 // Сигнатура функции для fourParams() взята с6 // https://www.4byte.directory/signatures/?bytes4_signature=0x3edc1e6d7 bytes4 constant FOUR_PARAMS = 0x3edc1e6d;89 // Просто несколько постоянных значений, чтобы убедиться, что мы получаем правильные значения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,45 // Первое значение в кеше6 bytes1(0x01),Ключи кеша размером менее 16 — это всего один байт.
1 // Второе значение, не добавляйте его в кеш2 cache.DONT_CACHE(),3 bytes32(VAL_B),45 // Третье и четвертое значения, одинаковые значения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 .45 _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 } // testEncodeValBig678 // Тестируем, что при слишком маленьком буфере мы получаем отмену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,89 // Первое значение, добавить его в кеш10 cache.INTO_CACHE(),11 bytes32(VAL_A),1213 // Второе значение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;1011 // Первый вызов, кеш пуст12 _callInput = bytes.concat(13 FOUR_PARAMS,1415 // Первое значение, добавить его в кеш16 cache.INTO_CACHE(), bytes32(VAL_A),1718 // Второе значение, добавить его в кеш19 cache.INTO_CACHE(), bytes32(VAL_B),2021 // Третье значение, добавить его в кеш22 cache.INTO_CACHE(), bytes32(VAL_C),2324 // Четвертое значение, добавить его в кеш25 cache.INTO_CACHE(), bytes32(VAL_D),2627 // И еще одно значение для «удачи»28 bytes4(0x31112233)29 );Показать всеЭта функция отправляет пять значений. Мы знаем, что пятое значение игнорируется, потому что оно не является допустимой записью кеша, что привело бы к отмене, если бы оно не было включено.
1 (_success, _callOutput) = _cacheAddr.call(_callInput);2 assertEq(_success, true);3 .4 .5 .6 } // testLongCalldata78} // CacheTest9Показать всеПример приложения
Писать тесты на Solidity — это, конечно, хорошо, но в конечном счете, чтобы быть полезным, децентрализованное приложение должно уметь обрабатывать запросы извне сети. В этой статье показано, как использовать кеширование в децентрализованном приложении с помощью WORM, что означает «Write Once, Read Many» (запись один раз, чтение много раз). Если ключ еще не записан, вы можете записать в него значение. Если ключ уже записан, вы получите отмену.
Контракт
Вот этот контрактopens in a new tab. Он в основном повторяет то, что мы уже сделали с Cache и CacheTest, так что мы рассмотрим только интересные части.
1import "./Cache.sol";23contract 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=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);34 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);23 .4 .5 .67 _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. Чтобы использовать его:
-
Клонируйте 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. Затем оно считывает эту запись и отображает используемый ключ и значения в записи (значение, номер блока и автор).
Большая часть клиента — это обычный JavaScript для децентрализованных приложений. Так что, опять же, мы рассмотрим только интересные части.
1.2.3.4const main = async () => {5 const func = await worm.WRITE_ENTRY_CACHED()67 // Каждый раз нужен новый ключ8 const key = await worm.encodeVal(Number(new Date()))В данный слот можно записать только один раз, поэтому мы используем временную метку, чтобы убедиться, что мы не будем повторно использовать слоты.
1const val = await worm.encodeVal("0x600D")23// Записать запись4const calldata = func + key.slice(2) + val.slice(2)Ethers ожидает, что данные вызова будут шестнадцатеричной строкой, 0x, за которым следует четное количество шестнадцатеричных цифр. Поскольку key и val начинаются с 0x, нам нужно удалить эти заголовки.
1const tx = await worm.populateTransaction.writeEntryCached()2tx.data = calldata34sentTx = await wallet.sendTransaction(tx)Как и в случае с тестовым кодом Solidity, мы не можем вызвать кешированную функцию обычным способом. Вместо этого нам нужно использовать механизм более низкого уровня.
1 .2 .3 .4 // Прочитать только что сделанную запись5 const realKey = '0x' + key.slice(4) // удалить флаг FF6 const entryRead = await worm.readEntry(realKey)7 .8 .9 .Показать всеДля чтения записей можно использовать обычный механизм. Для функций view нет необходимости использовать кеширование параметров.
Заключение
Код в этой статье является доказательством концепции, цель которой — сделать идею легкой для понимания. Для готовой к производству системы вы можете захотеть реализовать некоторые дополнительные функции:
-
Обработка значений, которые не являются
uint256. Например, строки. -
Вместо глобального кеша, возможно, стоит использовать сопоставление между пользователями и кешами. Разные пользователи используют разные значения.
-
Значения, используемые для адресов, отличаются от тех, что используются для других целей. Возможно, имеет смысл иметь отдельный кеш только для адресов.
-
В настоящее время ключи кеша работают по алгоритму «первым пришел — наименьший ключ». Первые шестнадцать значений могут быть отправлены в виде одного байта. Следующие 4080 значений могут быть отправлены в виде двух байтов. Следующий примерно миллион значений — это три байта и т. д. Производственная система должна вести счетчики использования записей кеша и реорганизовывать их так, чтобы шестнадцать наиболее распространенных значений занимали один байт, следующие 4080 наиболее распространенных значений — два байта и т. д.
Однако это потенциально опасная операция. Представьте себе следующую последовательность событий:
-
Ноам Наив вызывает
encodeValдля кодирования адреса, на который он хочет отправить токены. Этот адрес является одним из первых, используемых в приложении, поэтому закодированное значение — 0x06. Это функцияview, а не транзакция, поэтому это происходит между Ноамом и узлом, который он использует, и никто другой об этом не знает. -
Оуэн Оунер запускает операцию по реорганизации кеша. Очень немногие люди действительно используют этот адрес, поэтому теперь он закодирован как 0x201122. Другому значению, 1018, присваивается 0x06.
-
Ноам Наив отправляет свои токены на 0x06. Они отправляются на адрес
0x0000000000000000000000000de0b6b3a7640000, и, поскольку никто не знает приватный ключ для этого адреса, они просто застревают там. Ноам недоволен.
Есть способы решить эту проблему и связанную с ней проблему транзакций, которые находятся в мемпуле во время реорганизации кеша, но вы должны знать об этом.
-
Я продемонстрировал кеширование здесь с помощью Optimism, потому что я сотрудник Optimism, и это ролл-ап, который я знаю лучше всего. Но это должно работать с любым ролл-апом, который взимает минимальную плату за внутреннюю обработку, так что по сравнению с этим запись данных транзакции в L1 является основной статьей расходов.
Больше моих работ смотрите здесьopens in a new tab.
Последнее обновление страницы: 25 февраля 2026 г.