Всё, что можно кэшировать
При использовании роллапов стоимость байта в транзакции намного выше стоимости слота хранения. Поэтому имеет смысл кэшировать как можно больше информации ончейн.
В этой статье вы узнаете, как создать и использовать контракт кэширования таким образом, чтобы любое значение параметра, которое, вероятно, будет использоваться несколько раз, кэшировалось и было доступно для использования (после первого раза) с гораздо меньшим количеством байтов, а также как написать офчейн-код, использующий этот кэш.
Если вы хотите пропустить статью и просто посмотреть исходный код, он находится здесь (opens in a new tab). Стек разработки — Foundry (opens in a new tab).
Общая архитектура
Для простоты предположим, что все параметры транзакции имеют тип uint256 длиной 32 байта. При получении транзакции мы будем анализировать каждый параметр следующим образом:
-
Если первый байт равен
0xFF, берем следующие 32 байта как значение параметра и записываем его в кэш. -
Если первый байт равен
0xFE, берем следующие 32 байта как значение параметра, но не записываем его в кэш. -
Для любого другого значения берем старшие четыре бита как количество дополнительных байтов, а младшие четыре бита — как старшие биты ключа кэша. Вот несколько примеров:
Байты в данных вызова Ключ кэша 0x0F 0x0F 0x10,0x10 0x10 0x12,0xAC 0x02AC 0x2D,0xEA, 0xD6 0x0DEAD6
Управление кэшем
Кэш реализован в Cache.sol (opens in a new tab). Давайте разберем его построчно.
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
contract Cache {
bytes1 public constant INTO_CACHE = 0xFF;
bytes1 public constant DONT_CACHE = 0xFE;
Эти константы используются для интерпретации особых случаев, когда мы предоставляем всю информацию и хотим, чтобы она была записана в кэш или нет. Запись в кэш требует двух операций SSTORE (opens in a new tab) в ранее неиспользованные слоты хранения стоимостью 22100 газа каждая, поэтому мы делаем ее необязательной.
mapping(uint => uint) public val2key;
Отображение (mapping) (opens in a new tab) между значениями и их ключами. Эта информация необходима для кодирования значений перед отправкой транзакции.
// В ячейке n хранится значение для ключа n+1, потому что нам нужно сохранить
// ноль как «нет в кэше».
uint[] public key2val;
Мы можем использовать массив для отображения ключей в значения, потому что мы сами назначаем ключи, и для простоты делаем это последовательно.
function cacheRead(uint _key) public view returns (uint) {
require(_key <= key2val.length, "Reading uninitialize cache entry");
return key2val[_key-1];
} // cacheRead
Чтение значения из кэша.
// Записать значение в кэш, если его там еще нет
// Сделано public только для того, чтобы работал тест
function cacheWrite(uint _value) public returns (uint) {
// Если значение уже есть в кэше, вернуть текущий ключ
if (val2key[_value] != 0) {
return val2key[_value];
}
Нет смысла помещать одно и то же значение в кэш более одного раза. Если значение уже там, просто возвращаем существующий ключ.
// Поскольку 0xFE — особый случай, самый большой ключ, который может
// вместить кэш, — это 0x0D, за которым следуют 15 0xFF. Если длина кэша уже достигла этого
// размера, завершить с ошибкой.
// 1 2 3 4 5 6 7 8 9 A B C D E F
require(key2val.length+1 < 0x0DFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF,
"cache overflow");
Не думаю, что у нас когда-нибудь будет такой большой кэш (примерно 1.8*1037 записей, для хранения которых потребуется около 1027 ТБ). Однако я достаточно стар, чтобы помнить фразу «640 КБ хватит всем» (opens in a new tab). Эта проверка обходится очень дешево.
// Записать значение, используя следующий ключ
val2key[_value] = key2val.length+1;
Добавление обратного поиска (от значения к ключу).
key2val.push(_value);
Добавление прямого поиска (от ключа к значению). Поскольку мы назначаем значения последовательно, мы можем просто добавить его после последнего значения массива.
return key2val.length;
} // cacheWrite
Возвращаем новую длину key2val, которая является ячейкой, где хранится новое значение.
function _calldataVal(uint startByte, uint length)
private pure returns (uint)
Эта функция считывает значение из данных вызова произвольной длины (до 32 байт, размер слова).
{
uint _retVal;
require(length < 0x21,
"_calldataVal length limit is 32 bytes");
require(length + startByte <= msg.data.length,
"_calldataVal trying to read beyond calldatasize");
Эта функция является внутренней, поэтому, если остальной код написан правильно, эти проверки не требуются. Однако они стоят недорого, так что пусть будут.
assembly {
_retVal := calldataload(startByte)
}
Этот код написан на Yul (opens in a new tab). Он считывает 32-байтовое значение из данных вызова. Это работает, даже если данные вызова заканчиваются до startByte+32, потому что неинициализированное пространство в EVM считается равным нулю.
_retVal = _retVal >> (256-length*8);
Нам не обязательно нужно 32-байтовое значение. Это избавляет от лишних байтов.
return _retVal;
} // _calldataVal
// Считать один параметр из данных вызова, начиная с _fromByte
function _readParam(uint _fromByte) internal
returns (uint _nextByte, uint _parameterValue)
{
Чтение одного параметра из данных вызова. Обратите внимание, что нам нужно вернуть не только прочитанное значение, но и расположение следующего байта, поскольку длина параметров может варьироваться от 1 до 33 байт.
// Первый байт указывает нам, как интерпретировать остальное
uint8 _firstByte;
_firstByte = uint8(_calldataVal(_fromByte, 1));
Solidity пытается уменьшить количество ошибок, запрещая потенциально опасные неявные преобразования типов (opens in a new tab). Понижение типа, например, с 256 бит до 8 бит, должно быть явным.
// Считать значение, но не записывать его в кэш
if (_firstByte == uint8(DONT_CACHE))
return(_fromByte+33, _calldataVal(_fromByte+1, 32));
// Считать значение и записать его в кэш
if (_firstByte == uint8(INTO_CACHE)) {
uint _param = _calldataVal(_fromByte+1, 32);
cacheWrite(_param);
return(_fromByte+33, _param);
}
// Если мы попали сюда, значит, нам нужно считать из кэша
// Количество дополнительных байтов для чтения
uint8 _extraBytes = _firstByte / 16;
Берем младший полубайт (nibble) (opens in a new tab) и объединяем его с остальными байтами для чтения значения из кэша.
uint _key = (uint256(_firstByte & 0x0F) << (8*_extraBytes)) +
_calldataVal(_fromByte+1, _extraBytes);
return (_fromByte+_extraBytes+1, cacheRead(_key));
} // _readParam
// Считать n параметров (функции знают, сколько параметров они ожидают)
function _readParams(uint _paramNum) internal returns (uint[] memory) {
Мы могли бы получить количество параметров из самих данных вызова, но вызывающие нас функции знают, сколько параметров они ожидают. Проще позволить им сообщить нам об этом.
// Считанные нами параметры
uint[] memory params = new uint[](_paramNum);
// Параметры начинаются с 4-го байта, до этого идет сигнатура функции
uint _atByte = 4;
for(uint i=0; i<_paramNum; i++) {
(_atByte, params[i]) = _readParam(_atByte);
}
Считываем параметры, пока не получим нужное количество. Если мы выйдем за пределы данных вызова, _readParams вызовет откат.
return(params);
} // readParams
// Для тестирования _readParams, протестировать чтение четырех параметров
function fourParam() public
returns (uint256,uint256,uint256,uint256)
{
uint[] memory params;
params = _readParams(4);
return (params[0], params[1], params[2], params[3]);
} // fourParam
Одно из больших преимуществ Foundry заключается в том, что он позволяет писать тесты на Solidity (см. Тестирование кэша ниже). Это значительно упрощает модульное тестирование. Это функция, которая считывает четыре параметра и возвращает их, чтобы тест мог убедиться в их правильности.
// Получить значение, вернуть байты, которые его закодируют (используя кэш, если возможно)
function encodeVal(uint _val) public view returns(bytes memory) {
encodeVal — это функция, которую вызывает офчейн-код для помощи в создании данных вызова, использующих кэш. Она получает одно значение и возвращает байты, которые его кодируют. Эта функция имеет тип view, поэтому она не требует транзакции и при внешнем вызове не расходует газ.
uint _key = val2key[_val];
// Значения еще нет в кэше, добавить его
if (_key == 0)
return bytes.concat(INTO_CACHE, bytes32(_val));
В EVM все неинициализированное хранилище считается заполненным нулями. Поэтому, если мы ищем ключ для значения, которого там нет, мы получаем ноль. В этом случае байты, которые его кодируют, будут INTO_CACHE (чтобы оно было кэшировано в следующий раз), за которыми следует само значение.
// Если ключ <0x10, вернуть его как один байт
if (_key < 0x10)
return bytes.concat(bytes1(uint8(_key)));
Одиночные байты — самые простые. Мы просто используем bytes.concat (opens in a new tab), чтобы превратить тип bytes<n> в массив байтов, который может быть любой длины. Несмотря на название, он отлично работает, когда предоставляется только один аргумент.
// Двухбайтовое значение, закодированное как 0x1vvv
if (_key < 0x1000)
return bytes.concat(bytes2(uint16(_key) | 0x1000));
Когда у нас есть ключ, который меньше 163, мы можем выразить его в двух байтах. Сначала мы преобразуем _key, которое является 256-битным значением, в 16-битное значение и используем логическое ИЛИ, чтобы добавить количество дополнительных байтов к первому байту. Затем мы просто приводим его к значению bytes2, которое можно преобразовать в bytes.
// Вероятно, есть умный способ выполнить следующие строки в виде цикла,
// но это функция view, поэтому я оптимизирую время программиста и
// простоту.
if (_key < 16*256**2)
return bytes.concat(bytes3(uint24(_key) | (0x2 * 16 * 256**2)));
if (_key < 16*256**3)
return bytes.concat(bytes4(uint32(_key) | (0x3 * 16 * 256**3)));
.
.
.
if (_key < 16*256**14)
return bytes.concat(bytes15(uint120(_key) | (0xE * 16 * 256**14)));
if (_key < 16*256**15)
return bytes.concat(bytes16(uint128(_key) | (0xF * 16 * 256**15)));
Остальные значения (3 байта, 4 байта и т. д.) обрабатываются аналогичным образом, только с другими размерами полей.
// Если мы попали сюда, что-то не так.
revert("Error in encodeVal, should not happen");
Если мы дошли до этого места, значит, мы получили ключ, который не меньше 16*25615. Но cacheWrite ограничивает ключи, поэтому мы даже не можем дойти до 14*25616 (у которого первый байт был бы 0xFE, поэтому он выглядел бы как DONT_CACHE). Но нам ничего не стоит добавить проверку на случай, если будущий программист допустит ошибку.
} // encodeVal
} // Cache
Тестирование кэша
Одно из преимуществ Foundry заключается в том, что он позволяет писать тесты на Solidity (opens in a new tab), что упрощает написание модульных тестов. Тесты для класса Cache находятся здесь (opens in a new tab). Поскольку код тестирования повторяется, как это обычно бывает с тестами, в этой статье объясняются только самые интересные части.
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
import "forge-std/Test.sol";
// Нужно запустить `forge test -vv` для вывода в консоль.
import "forge-std/console.sol";
Это просто шаблонный код, необходимый для использования пакета тестирования и console.log.
import "src/Cache.sol";
Нам нужно знать контракт, который мы тестируем.
contract CacheTest is Test {
Cache cache;
function setUp() public {
cache = new Cache();
}
Функция setUp вызывается перед каждым тестом. В данном случае мы просто создаем новый кэш, чтобы наши тесты не влияли друг на друга.
function testCaching() public {
Тесты — это функции, имена которых начинаются с test. Эта функция проверяет базовую функциональность кэша: запись значений и их повторное чтение.
for(uint i=1; i<5000; i++) {
cache.cacheWrite(i*i);
}
for(uint i=1; i<5000; i++) {
assertEq(cache.cacheRead(i), i*i);
Вот как выполняется само тестирование с использованием функций assert... (opens in a new tab). В данном случае мы проверяем, что записанное нами значение совпадает с прочитанным. Мы можем отбросить результат cache.cacheWrite, потому что знаем, что ключи кэша назначаются линейно.
}
} // testCaching
// Кэшировать одно и то же значение несколько раз, убедиться, что ключ остается
// тем же
function testRepeatCaching() public {
for(uint i=1; i<100; i++) {
uint _key1 = cache.cacheWrite(i);
uint _key2 = cache.cacheWrite(i);
assertEq(_key1, _key2);
}
Сначала мы дважды записываем каждое значение в кэш и убеждаемся, что ключи совпадают (это означает, что вторая запись на самом деле не произошла).
for(uint i=1; i<100; i+=3) {
uint _key = cache.cacheWrite(i);
assertEq(_key, i);
}
} // testRepeatCaching
Теоретически может существовать ошибка, которая не влияет на последовательные записи в кэш. Поэтому здесь мы выполняем несколько непоследовательных записей и видим, что значения по-прежнему не перезаписываются.
// Считать uint из буфера памяти (чтобы убедиться, что мы получаем обратно параметры,
// которые мы отправили)
function toUint256(bytes memory _bytes, uint256 _start) internal pure
returns (uint256)
Чтение 256-битного слова из буфера bytes memory. Эта служебная функция позволяет нам убедиться, что мы получаем правильные результаты при выполнении вызова функции, использующей кэш.
{
require(_bytes.length >= _start + 32, "toUint256_outOfBounds");
uint256 tempUint;
assembly {
tempUint := mload(add(add(_bytes, 0x20), _start))
}
Yul не поддерживает структуры данных сложнее uint256, поэтому при обращении к более сложной структуре данных, такой как буфер памяти _bytes, вы получаете адрес этой структуры. Solidity хранит значения bytes memory в виде 32-байтового слова, содержащего длину, за которым следуют сами байты, поэтому для получения байта с номером _start нам нужно вычислить _bytes+32+_start.
return tempUint;
} // toUint256
// Сигнатура функции для fourParams(), предоставлена
// https://www.4byte.directory/signatures/?bytes4_signature=0x3edc1e6d
bytes4 constant FOUR_PARAMS = 0x3edc1e6d;
// Просто некоторые константные значения, чтобы убедиться, что мы получаем правильные значения обратно
uint256 constant VAL_A = 0xDEAD60A7;
uint256 constant VAL_B = 0xBEEF;
uint256 constant VAL_C = 0x600D;
uint256 constant VAL_D = 0x600D60A7;
Некоторые константы, необходимые нам для тестирования.
function testReadParam() public {
Вызов fourParams(), функции, использующей readParams, для проверки правильности чтения параметров.
address _cacheAddr = address(cache);
bool _success;
bytes memory _callInput;
bytes memory _callOutput;
Мы не можем использовать обычный механизм ABI для вызова функции с использованием кэша, поэтому нам нужно использовать низкоуровневый механизм <address>.call() (opens in a new tab). Этот механизм принимает bytes memory в качестве входных данных и возвращает его же (а также логическое значение) в качестве выходных данных.
// Первый вызов, кэш пуст
_callInput = bytes.concat(
FOUR_PARAMS,
Полезно, чтобы один и тот же контракт поддерживал как кэшированные функции (для вызовов непосредственно из транзакций), так и некэшированные функции (для вызовов из других смарт-контрактов). Для этого нам нужно продолжать полагаться на механизм Solidity для вызова правильной функции, вместо того чтобы помещать все в функцию fallback (opens in a new tab). Это значительно упрощает композируемость. В большинстве случаев для идентификации функции было бы достаточно одного байта, поэтому мы тратим впустую три байта (16*3=48 газа). Однако на момент написания этой статьи эти 48 единиц газа стоят 0,07 цента, что является разумной платой за более простой и менее подверженный ошибкам код.
// Первое значение, добавить его в кэш
cache.INTO_CACHE(),
bytes32(VAL_A),
Первое значение: флаг, указывающий, что это полное значение, которое необходимо записать в кэш, за которым следуют 32 байта значения. Остальные три значения аналогичны, за исключением того, что VAL_B не записывается в кэш, а VAL_C является как третьим, так и четвертым параметром.
.
.
.
);
(_success, _callOutput) = _cacheAddr.call(_callInput);
Здесь мы фактически вызываем контракт Cache.
assertEq(_success, true);
Мы ожидаем, что вызов будет успешным.
assertEq(cache.cacheRead(1), VAL_A);
assertEq(cache.cacheRead(2), VAL_C);
Мы начинаем с пустого кэша, а затем добавляем VAL_A, за которым следует VAL_C. Мы ожидаем, что первый получит ключ 1, а второй — 2.
assertEq(toUint256(_callOutput,0), VAL_A);
assertEq(toUint256(_callOutput,32), VAL_B);
assertEq(toUint256(_callOutput,64), VAL_C);
assertEq(toUint256(_callOutput,96), VAL_C);
На выходе получаем четыре параметра. Здесь мы проверяем их правильность.
// Второй вызов, мы можем использовать кэш
_callInput = bytes.concat(
FOUR_PARAMS,
// Первое значение в кэше
bytes1(0x01),
Ключи кэша меньше 16 занимают всего один байт.
// Второе значение, не добавлять его в кэш
cache.DONT_CACHE(),
bytes32(VAL_B),
// Третье и четвертое значения, одно и то же значение
bytes1(0x02),
bytes1(0x02)
);
.
.
.
} // testReadParam
Проверки после вызова идентичны проверкам после первого вызова.
function testEncodeVal() public {
Эта функция похожа на testReadParam, за исключением того, что вместо явной записи параметров мы используем encodeVal().
.
.
.
_callInput = bytes.concat(
FOUR_PARAMS,
cache.encodeVal(VAL_A),
cache.encodeVal(VAL_B),
cache.encodeVal(VAL_C),
cache.encodeVal(VAL_D)
);
.
.
.
assertEq(_callInput.length, 4+1*4);
} // testEncodeVal
Единственная дополнительная проверка в testEncodeVal() — это проверка правильности длины _callInput. Для первого вызова она равна 4+33*4. Для второго, где каждое значение уже находится в кэше, она равна 4+1*4.
// Протестировать encodeVal, когда ключ занимает больше одного байта
// Максимум три байта, потому что заполнение кэша до четырех байтов занимает
// слишком много времени.
function testEncodeValBig() public {
// Поместить несколько значений в кэш.
// Для простоты использовать ключ n для значения n.
for(uint i=1; i<0x1FFF; i++) {
cache.cacheWrite(i);
}
Функция testEncodeVal выше записывает в кэш только четыре значения, поэтому часть функции, которая работает с многобайтовыми значениями (opens in a new tab), не проверяется. Но этот код сложен и подвержен ошибкам.
Первая часть этой функции представляет собой цикл, который по порядку записывает все значения от 1 до 0x1FFF в кэш, чтобы мы могли закодировать эти значения и знать, куда они попадают.
.
.
.
_callInput = bytes.concat(
FOUR_PARAMS,
cache.encodeVal(0x000F), // Один байт 0x0F
cache.encodeVal(0x0010), // Два байта 0x1010
cache.encodeVal(0x0100), // Два байта 0x1100
cache.encodeVal(0x1000) // Три байта 0x201000
);
Тестирование однобайтовых, двухбайтовых и трехбайтовых значений. Мы не тестируем дальше, потому что потребовалось бы слишком много времени для записи достаточного количества элементов стека (как минимум 0x10000000, примерно четверть миллиарда).
.
.
.
.
} // testEncodeValBig
// Проверить, что при слишком маленьком буфере мы получаем откат
function testShortCalldata() public {
Проверка того, что происходит в нештатной ситуации, когда параметров недостаточно.
.
.
.
(_success, _callOutput) = _cacheAddr.call(_callInput);
assertEq(_success, false);
} // testShortCalldata
Поскольку происходит откат, мы должны получить результат false.
// Вызов с ключами кэша, которых нет
function testNoCacheKey() public {
.
.
.
_callInput = bytes.concat(
FOUR_PARAMS,
// Первое значение, добавляем его в кэш
cache.INTO_CACHE(),
bytes32(VAL_A),
// Второе значение
bytes1(0x0F),
bytes2(0x1234),
bytes11(0xA10102030405060708090A)
);
Эта функция получает четыре совершенно правильных параметра, за исключением того, что кэш пуст, поэтому там нет значений для чтения.
.
.
.
// Проверить, что при слишком длинном буфере все работает нормально
function testLongCalldata() public {
address _cacheAddr = address(cache);
bool _success;
bytes memory _callInput;
bytes memory _callOutput;
// Первый вызов, кэш пуст
_callInput = bytes.concat(
FOUR_PARAMS,
// Первое значение, добавить его в кэш
cache.INTO_CACHE(), bytes32(VAL_A),
// Второе значение, добавить его в кэш
cache.INTO_CACHE(), bytes32(VAL_B),
// Третье значение, добавить его в кэш
cache.INTO_CACHE(), bytes32(VAL_C),
// Четвертое значение, добавить его в кэш
cache.INTO_CACHE(), bytes32(VAL_D),
// И еще одно значение «на удачу»
bytes4(0x31112233)
);
Эта функция отправляет пять значений. Мы знаем, что пятое значение игнорируется, поскольку оно не является допустимой записью кэша, что вызвало бы откат, если бы оно не было включено.
(_success, _callOutput) = _cacheAddr.call(_callInput);
assertEq(_success, true);
.
.
.
} // testLongCalldata
} // CacheTest
Пример приложения
Писать тесты на Solidity — это, конечно, хорошо, но в конечном итоге децентрализованное приложение (dapp) должно уметь обрабатывать запросы извне цепи, чтобы быть полезным. В этой статье демонстрируется, как использовать кэширование в dapp с помощью WORM, что означает «Write Once, Read Many» (Запиши один раз, читай много раз). Если ключ еще не записан, вы можете записать в него значение. Если ключ уже записан, вы получаете откат.
Контракт
Вот этот контракт (opens in a new tab). Он в основном повторяет то, что мы уже сделали с Cache и CacheTest, поэтому мы рассмотрим только интересные части.
import "./Cache.sol";
contract WORM is Cache {
Самый простой способ использовать Cache — унаследовать его в нашем собственном контракте.
function writeEntryCached() external {
uint[] memory params = _readParams(2);
writeEntry(params[0], params[1]);
} // writeEntryCached
Эта функция похожа на fourParam в CacheTest выше. Поскольку мы не следуем спецификациям ABI, лучше не объявлять никаких параметров в функции.
// Сделать так, чтобы нас было проще вызвать
// Сигнатура функции для writeEntryCached(), предоставлена
// https://www.4byte.directory/signatures/?bytes4_signature=0xe4e4f2d3
bytes4 constant public WRITE_ENTRY_CACHED = 0xe4e4f2d3;
Внешнему коду, вызывающему writeEntryCached, потребуется вручную создавать данные вызова вместо использования worm.writeEntryCached, поскольку мы не следуем спецификациям ABI. Наличие этого константного значения просто упрощает его написание.
Обратите внимание, что хотя мы определяем WRITE_ENTRY_CACHED как переменную состояния, для ее чтения извне необходимо использовать функцию-геттер для нее — worm.WRITE_ENTRY_CACHED().
function readEntry(uint key) public view
returns (uint _value, address _writtenBy, uint _writtenAtBlock)
Функция чтения имеет тип view, поэтому она не требует транзакции и не расходует газ. В результате нет никакой выгоды от использования кэша для параметра. С функциями view лучше использовать стандартный механизм, который проще.
Код тестирования
Это код тестирования для контракта (opens in a new tab). Опять же, давайте посмотрим только на то, что интересно.
function testWReadWrite() public {
worm.writeEntry(0xDEAD, 0x60A7);
vm.expectRevert(bytes("entry already written"));
worm.writeEntry(0xDEAD, 0xBEEF);
Вот так (vm.expectRevert) (opens in a new tab) мы указываем в тесте Foundry, что следующий вызов должен завершиться ошибкой, и сообщаем причину сбоя. Это применимо, когда мы используем синтаксис <contract>.<function name>(), а не создаем данные вызова и не вызываем контракт с использованием низкоуровневого интерфейса (<contract>.call() и т. д.).
function testReadWriteCached() public {
uint cacheGoat = worm.cacheWrite(0x60A7);
Здесь мы используем тот факт, что cacheWrite возвращает ключ кэша. Это не то, что мы ожидали бы использовать в рабочей среде, потому что cacheWrite изменяет состояние и, следовательно, может быть вызван только во время транзакции. Транзакции не имеют возвращаемых значений; если у них есть результаты, эти результаты должны генерироваться как события. Таким образом, возвращаемое значение cacheWrite доступно только из ончейн-кода, а ончейн-коду не нужно кэширование параметров.
(_success,) = address(worm).call(_callInput);
Вот как мы сообщаем Solidity, что хотя <contract address>.call() имеет два возвращаемых значения, нас интересует только первое.
(_success,) = address(worm).call(_callInput);
assertEq(_success, false);
Поскольку мы используем низкоуровневую функцию <address>.call(), мы не можем использовать vm.expectRevert() и должны смотреть на логическое значение успеха, которое мы получаем от вызова.
event EntryWritten(uint indexed key, uint indexed value);
.
.
.
_callInput = bytes.concat(
worm.WRITE_ENTRY_CACHED(), worm.encodeVal(a), worm.encodeVal(b));
vm.expectEmit(true, true, false, false);
emit EntryWritten(a, b);
(_success,) = address(worm).call(_callInput);
Таким образом мы проверяем, что код правильно генерирует событие (opens in a new tab) в Foundry.
Клиент
Чего вы не получите с тестами на Solidity, так это кода на JavaScript, который можно скопировать и вставить в собственное приложение. В оригинальной версии этого руководства контракт WORM развертывался в сети Optimism Гёрли, которая с тех пор была выведена из эксплуатации. Чтобы запустить клиент сегодня, повторно разверните WORM в поддерживаемой сети OP Stack, такой как OP Sepolia (opens in a new tab), а затем используйте полученный адрес контракта в клиенте на JavaScript.
Код клиента на JavaScript можно посмотреть здесь (opens in a new tab). Пример репозитория был написан для Optimism Гёрли, поэтому перед его запуском обновите URL-адреса конечной точки RPC и обозревателя в файлах javascript/.env.example и javascript/index.js для вашей целевой сети. Чтобы использовать его:
-
Клонируйте git-репозиторий:
git clone https://github.com/qbzzt/20220915-all-you-can-cache.git -
Установите необходимые пакеты:
cd javascript yarn -
Скопируйте файл конфигурации:
cp .env.example .env -
Отредактируйте
.envдля вашей конфигурации:Параметр Значение MNEMONIC Мнемоническая фраза для аккаунта, на котором достаточно ETH для оплаты транзакции. В документации по кранам Optimism (opens in a new tab) перечислены текущие краны тестовой сети. OPTIMISM_GOERLI_URL URL-адрес RPC для сети, в которой вы повторно развертываете WORM. Для OP Sepolia используйте конечную точку RPC OP Sepolia, например https://sepolia.optimism.io, или другую конечную точку от вашего провайдера. -
Запустите
index.js.node index.jsЭтот пример приложения сначала создает запись в WORM, отображая данные вызова и ссылку на транзакцию в обозревателе блоков. Затем оно считывает эту запись и отображает используемый ключ и значения в записи (значение, номер блока и автора).
Большая часть клиента — это обычный код децентрализованного приложения (dapp) на JavaScript. Поэтому мы снова рассмотрим только самые интересные части.
.
.
.
const main = async () => {
const func = await worm.WRITE_ENTRY_CACHED()
// Каждый раз нужен новый ключ
const key = await worm.encodeVal(Number(new Date()))
В каждый слот можно записать только один раз, поэтому мы используем временную метку, чтобы убедиться, что мы не используем слоты повторно.
const val = await worm.encodeVal("0x600D")
// Записать запись
const calldata = func + key.slice(2) + val.slice(2)
Ethers ожидает, что данные вызова будут шестнадцатеричной строкой: 0x, за которым следует четное количество шестнадцатеричных цифр. Поскольку key и val начинаются с 0x, нам нужно удалить эти заголовки.
const tx = await worm.populateTransaction.writeEntryCached()
tx.data = calldata
sentTx = await wallet.sendTransaction(tx)
Как и в случае с кодом тестирования на Solidity, мы не можем вызвать кэшированную функцию обычным способом. Вместо этого нам нужно использовать низкоуровневый механизм.
.
.
.
// Считать только что записанную запись
const realKey = '0x' + key.slice(4) // удалить флаг FF
const entryRead = await worm.readEntry(realKey)
.
.
.
Для чтения записей мы можем использовать обычный механизм. Нет необходимости использовать кэширование параметров с функциями view.
Заключение
Код в этой статье является проверкой концепции (proof of concept), цель которой — сделать идею простой для понимания. Для готовой к использованию в рабочей среде системы вы, возможно, захотите реализовать некоторые дополнительные функции:
-
Обработка значений, которые не являются
uint256. Например, строк. -
Вместо глобального кэша, возможно, стоит создать отображение между пользователями и кэшами. Разные пользователи используют разные значения.
-
Значения, используемые для адресов, отличаются от значений, используемых для других целей. Возможно, имеет смысл создать отдельный кэш только для адресов.
-
В настоящее время ключи кэша работают по алгоритму «первым пришел — наименьший ключ». Первые шестнадцать значений могут быть отправлены как один байт. Следующие 4080 значений могут быть отправлены как два байта. Следующий примерно миллион значений — это три байта и т. д. Рабочая система должна вести счетчики использования записей кэша и реорганизовывать их так, чтобы шестнадцать наиболее часто используемых значений занимали один байт, следующие 4080 наиболее часто используемых значений — два байта и т. д.
Однако это потенциально опасная операция. Представьте себе следующую последовательность событий:
-
Наивный Ноам вызывает
encodeVal, чтобы закодировать адрес, на который он хочет отправить токены. Этот адрес является одним из первых, используемых в приложении, поэтому закодированное значение равно 0x06. Это функцияview, а не транзакция, поэтому она происходит между Ноамом и узлом, который он использует, и никто другой об этом не знает. -
Владелец Оуэн запускает операцию изменения порядка кэша. Очень мало людей на самом деле используют этот адрес, поэтому теперь он закодирован как 0x201122. Другому значению, 1018, присваивается 0x06.
-
Наивный Ноам отправляет свои токены на 0x06. Они уходят на адрес
0x0000000000000000000000000de0b6b3a7640000, и поскольку никто не знает приватный ключ для этого адреса, они просто застревают там. Ноам не счастлив.
Существуют способы решения этой проблемы, а также связанной с ней проблемы транзакций, которые находятся в мемпуле во время изменения порядка кэша, но вы должны знать о ней.
-
Я продемонстрировал здесь кэширование с помощью Optimism, потому что я являюсь сотрудником Optimism, и это роллап, который я знаю лучше всего. Но это должно работать с любым роллапом, который взимает минимальную плату за внутреннюю обработку, так что по сравнению с этим запись данных транзакции на уровень 1 (l1) является основной статьей расходов.