Todo lo que puedas almacenar en caché
Al usar rollups, el costo de un byte en la transacción es mucho más caro que el costo de un slot de almacenamiento. Por lo tanto, tiene sentido almacenar en caché tanta información como sea posible en cadena.
En este artículo aprenderá a crear y usar un contrato de almacenamiento en caché de tal manera que cualquier valor de parámetro que probablemente se use varias veces se almacene en caché y esté disponible para su uso (después de la primera vez) con un número mucho menor de bytes, y cómo escribir código fuera de la cadena que use esta caché.
Si desea omitir el artículo y solo ver el código fuente, está aquí (opens in a new tab). El entorno de desarrollo es Foundry (opens in a new tab).
Diseño general
Para simplificar, asumiremos que todos los parámetros de la transacción son uint256, de 32 bytes de longitud. Cuando recibimos una transacción, analizaremos cada parámetro de la siguiente manera:
-
Si el primer byte es
0xFF, tome los siguientes 32 bytes como valor de parámetro y escríbalo en la caché. -
Si el primer byte es
0xFE, tome los siguientes 32 bytes como valor de parámetro pero no lo escriba en la caché. -
Para cualquier otro valor, tome los cuatro bits superiores como el número de bytes adicionales, y los cuatro bits inferiores como los bits más significativos de la clave de caché. Aquí hay algunos ejemplos:
Bytes en los datos de llamada Clave de caché 0x0F 0x0F 0x10,0x10 0x10 0x12,0xAC 0x02AC 0x2D,0xEA, 0xD6 0x0DEAD6
Manipulación de la caché
La caché se implementa en Cache.sol (opens in a new tab). Repasémoslo línea por línea.
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
contract Cache {
bytes1 public constant INTO_CACHE = 0xFF;
bytes1 public constant DONT_CACHE = 0xFE;
Estas constantes se utilizan para interpretar los casos especiales en los que proporcionamos toda la información y queremos que se escriba en la caché o no. Escribir en la caché requiere dos operaciones SSTORE (opens in a new tab) en slots de almacenamiento no utilizados previamente a un costo de 22100 de gas cada una, por lo que lo hacemos opcional.
mapping(uint => uint) public val2key;
Un mapeo (opens in a new tab) entre los valores y sus claves. Esta información es necesaria para codificar los valores antes de enviar la transacción.
// La ubicación n tiene el valor para la clave n+1, porque necesitamos preservar
// el cero como "no está en la caché".
uint[] public key2val;
Podemos usar un array para el mapeo de claves a valores porque nosotros asignamos las claves, y por simplicidad lo hacemos secuencialmente.
function cacheRead(uint _key) public view returns (uint) {
require(_key <= key2val.length, "Reading uninitialize cache entry");
return key2val[_key-1];
} // cacheRead
Lee un valor de la caché.
// Escribir un valor en la caché si aún no está allí
// Solo es público para permitir que la prueba funcione
function cacheWrite(uint _value) public returns (uint) {
// Si el valor ya está en la caché, devolver la clave actual
if (val2key[_value] != 0) {
return val2key[_value];
}
No tiene sentido poner el mismo valor en la caché más de una vez. Si el valor ya está ahí, simplemente devuelve la clave existente.
// Dado que 0xFE es un caso especial, la clave más grande que la caché puede
// contener es 0x0D seguido de 15 0xFF. Si la longitud de la caché ya es tan
// grande, fallar.
// 1 2 3 4 5 6 7 8 9 A B C D E F
require(key2val.length+1 < 0x0DFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF,
"cache overflow");
No creo que alguna vez tengamos una caché tan grande (aproximadamente 1.8*1037 entradas, lo que requeriría alrededor de 1027 TB para almacenar). Sin embargo, soy lo suficientemente mayor como para recordar que "640kB siempre serían suficientes" (opens in a new tab). Esta prueba es muy barata.
// Escribir el valor usando la siguiente clave
val2key[_value] = key2val.length+1;
Añade la búsqueda inversa (del valor a la clave).
key2val.push(_value);
Añade la búsqueda directa (de la clave al valor). Debido a que asignamos valores secuencialmente, simplemente podemos añadirlo después del último valor del array.
return key2val.length;
} // cacheWrite
Devuelve la nueva longitud de key2val, que es la celda donde se almacena el nuevo valor.
function _calldataVal(uint startByte, uint length)
private pure returns (uint)
Esta función lee un valor de los datos de llamada de longitud arbitraria (hasta 32 bytes, el tamaño de la palabra).
{
uint _retVal;
require(length < 0x21,
"_calldataVal length limit is 32 bytes");
require(length + startByte <= msg.data.length,
"_calldataVal trying to read beyond calldatasize");
Esta función es interna, por lo que si el resto del código está escrito correctamente, estas pruebas no son necesarias. Sin embargo, no cuestan mucho, así que bien podríamos tenerlas.
assembly {
_retVal := calldataload(startByte)
}
Este código está en Yul (opens in a new tab). Lee un valor de 32 bytes de los datos de llamada. Esto funciona incluso si los datos de llamada se detienen antes de startByte+32 porque el espacio no inicializado en la EVM se considera cero.
_retVal = _retVal >> (256-length*8);
No necesariamente queremos un valor de 32 bytes. Esto elimina los bytes sobrantes.
return _retVal;
} // _calldataVal
// Leer un solo parámetro de los datos de llamada, comenzando en _fromByte
function _readParam(uint _fromByte) internal
returns (uint _nextByte, uint _parameterValue)
{
Lee un solo parámetro de los datos de llamada. Tenga en cuenta que necesitamos devolver no solo el valor que leemos, sino también la ubicación del siguiente byte porque los parámetros pueden variar de 1 byte a 33 bytes de longitud.
// El primer byte nos dice cómo interpretar el resto
uint8 _firstByte;
_firstByte = uint8(_calldataVal(_fromByte, 1));
Solidity intenta reducir el número de errores prohibiendo las conversiones de tipo implícitas (opens in a new tab) potencialmente peligrosas. Una reducción, por ejemplo de 256 bits a 8 bits, debe ser explícita.
// Leer el valor, pero no escribirlo en la caché
if (_firstByte == uint8(DONT_CACHE))
return(_fromByte+33, _calldataVal(_fromByte+1, 32));
// Leer el valor y escribirlo en la caché
if (_firstByte == uint8(INTO_CACHE)) {
uint _param = _calldataVal(_fromByte+1, 32);
cacheWrite(_param);
return(_fromByte+33, _param);
}
// Si llegamos aquí significa que necesitamos leer de la caché
// Número de bytes adicionales a leer
uint8 _extraBytes = _firstByte / 16;
Tome el nibble (opens in a new tab) inferior y combínelo con los otros bytes para leer el valor de la caché.
uint _key = (uint256(_firstByte & 0x0F) << (8*_extraBytes)) +
_calldataVal(_fromByte+1, _extraBytes);
return (_fromByte+_extraBytes+1, cacheRead(_key));
} // _readParam
// Leer n parámetros (las funciones saben cuántos parámetros esperan)
function _readParams(uint _paramNum) internal returns (uint[] memory) {
Podríamos obtener el número de parámetros que tenemos de los propios datos de llamada, pero las funciones que nos llaman saben cuántos parámetros esperan. Es más fácil dejar que nos lo digan.
// Los parámetros que leemos
uint[] memory params = new uint[](_paramNum);
// Los parámetros comienzan en el byte 4, antes de eso es la firma de la función
uint _atByte = 4;
for(uint i=0; i<_paramNum; i++) {
(_atByte, params[i]) = _readParam(_atByte);
}
Lea los parámetros hasta que tenga el número que necesita. Si pasamos el final de los datos de llamada, _readParams revertirá la llamada.
return(params);
} // readParams
// Para probar _readParams, probar la lectura de cuatro parámetros
function fourParam() public
returns (uint256,uint256,uint256,uint256)
{
uint[] memory params;
params = _readParams(4);
return (params[0], params[1], params[2], params[3]);
} // fourParam
Una gran ventaja de Foundry es que permite escribir pruebas en Solidity (consulte Prueba de la caché a continuación). Esto hace que las pruebas unitarias sean mucho más fáciles. Esta es una función que lee cuatro parámetros y los devuelve para que la prueba pueda verificar que eran correctos.
// Obtener un valor, devolver los bytes que lo codificarán (usando la caché si es posible)
function encodeVal(uint _val) public view returns(bytes memory) {
encodeVal es una función que el código fuera de la cadena llama para ayudar a crear datos de llamada que usan la caché. Recibe un solo valor y devuelve los bytes que lo codifican. Esta función es un view, por lo que no requiere una transacción y cuando se llama externamente no cuesta nada de gas.
uint _key = val2key[_val];
// El valor aún no está en la caché, agregarlo
if (_key == 0)
return bytes.concat(INTO_CACHE, bytes32(_val));
En la EVM todo el almacenamiento no inicializado se asume como ceros. Así que si buscamos la clave para un valor que no está ahí, obtenemos un cero. En ese caso, los bytes que lo codifican son INTO_CACHE (para que se almacene en caché la próxima vez), seguidos del valor real.
// Si la clave es <0x10, devolverla como un solo byte
if (_key < 0x10)
return bytes.concat(bytes1(uint8(_key)));
Los bytes individuales son los más fáciles. Simplemente usamos bytes.concat (opens in a new tab) para convertir un tipo bytes<n> en un array de bytes que puede tener cualquier longitud. A pesar del nombre, funciona bien cuando se proporciona con un solo argumento.
// Valor de dos bytes, codificado como 0x1vvv
if (_key < 0x1000)
return bytes.concat(bytes2(uint16(_key) | 0x1000));
Cuando tenemos una clave que es menor que 163, podemos expresarla en dos bytes. Primero convertimos _key, que es un valor de 256 bits, a un valor de 16 bits y usamos un OR lógico para añadir el número de bytes adicionales al primer byte. Luego simplemente lo convertimos en un valor bytes2, que se puede convertir a bytes.
// Probablemente haya una forma inteligente de hacer las siguientes líneas como un bucle,
// pero es una función view, así que estoy optimizando el tiempo del programador y
// la simplicidad.
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)));
Los otros valores (3 bytes, 4 bytes, etc.) se manejan de la misma manera, solo que con diferentes tamaños de campo.
// Si llegamos aquí, algo anda mal.
revert("Error in encodeVal, should not happen");
Si llegamos aquí significa que obtuvimos una clave que no es menor que 16*25615. Pero cacheWrite limita las claves para que ni siquiera podamos llegar a 14*25616 (que tendría un primer byte de 0xFE, por lo que se vería como DONT_CACHE). Pero no nos cuesta mucho añadir una prueba en caso de que un futuro programador introduzca un error.
} // encodeVal
} // Cache
Prueba de la caché
Una de las ventajas de Foundry es que le permite escribir pruebas en Solidity (opens in a new tab), lo que facilita la escritura de pruebas unitarias. Las pruebas para la clase Cache están aquí (opens in a new tab). Debido a que el código de prueba es repetitivo, como suelen ser las pruebas, este artículo solo explica las partes interesantes.
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
import "forge-std/Test.sol";
// Es necesario ejecutar `forge test -vv` para la consola.
import "forge-std/console.sol";
Esto es solo código repetitivo que es necesario para usar el paquete de prueba y console.log.
import "src/Cache.sol";
Necesitamos conocer el contrato que estamos probando.
contract CacheTest is Test {
Cache cache;
function setUp() public {
cache = new Cache();
}
La función setUp se llama antes de cada prueba. En este caso, simplemente creamos una nueva caché, para que nuestras pruebas no se afecten entre sí.
function testCaching() public {
Las pruebas son funciones cuyos nombres comienzan con test. Esta función comprueba la funcionalidad básica de la caché, escribiendo valores y leyéndolos de nuevo.
for(uint i=1; i<5000; i++) {
cache.cacheWrite(i*i);
}
for(uint i=1; i<5000; i++) {
assertEq(cache.cacheRead(i), i*i);
Así es como se hace la prueba real, usando funciones assert... (opens in a new tab). En este caso, comprobamos que el valor que escribimos es el que leemos. Podemos descartar el resultado de cache.cacheWrite porque sabemos que las claves de caché se asignan linealmente.
}
} // testCaching
// Almacenar en caché el mismo valor varias veces, asegurarse de que la clave se mantenga
// igual
function testRepeatCaching() public {
for(uint i=1; i<100; i++) {
uint _key1 = cache.cacheWrite(i);
uint _key2 = cache.cacheWrite(i);
assertEq(_key1, _key2);
}
Primero escribimos cada valor dos veces en la caché y nos aseguramos de que las claves sean las mismas (lo que significa que la segunda escritura no ocurrió realmente).
for(uint i=1; i<100; i+=3) {
uint _key = cache.cacheWrite(i);
assertEq(_key, i);
}
} // testRepeatCaching
En teoría, podría haber un error que no afecte a las escrituras consecutivas en la caché. Así que aquí hacemos algunas escrituras que no son consecutivas y vemos que los valores aún no se reescriben.
// Leer un uint de un búfer de memoria (para asegurarnos de que recuperamos los parámetros
// que enviamos)
function toUint256(bytes memory _bytes, uint256 _start) internal pure
returns (uint256)
Lee una palabra de 256 bits de un búfer bytes memory. Esta función de utilidad nos permite verificar que recibimos los resultados correctos cuando ejecutamos una llamada a una función que usa la caché.
{
require(_bytes.length >= _start + 32, "toUint256_outOfBounds");
uint256 tempUint;
assembly {
tempUint := mload(add(add(_bytes, 0x20), _start))
}
Yul no admite estructuras de datos más allá de uint256, por lo que cuando se refiere a una estructura de datos más sofisticada, como el búfer de memoria _bytes, obtiene la dirección de esa estructura. Solidity almacena los valores bytes memory como una palabra de 32 bytes que contiene la longitud, seguida de los bytes reales, por lo que para obtener el número de byte _start necesitamos calcular _bytes+32+_start.
return tempUint;
} // toUint256
// Firma de la función para fourParams(), cortesía de
// https://www.4byte.directory/signatures/?bytes4_signature=0x3edc1e6d
bytes4 constant FOUR_PARAMS = 0x3edc1e6d;
// Solo algunos valores constantes para ver que estamos recuperando los valores correctos
uint256 constant VAL_A = 0xDEAD60A7;
uint256 constant VAL_B = 0xBEEF;
uint256 constant VAL_C = 0x600D;
uint256 constant VAL_D = 0x600D60A7;
Algunas constantes que necesitamos para las pruebas.
function testReadParam() public {
Llama a fourParams(), una función que usa readParams, para probar que podemos leer los parámetros correctamente.
address _cacheAddr = address(cache);
bool _success;
bytes memory _callInput;
bytes memory _callOutput;
No podemos usar el mecanismo ABI normal para llamar a una función usando la caché, por lo que necesitamos usar el mecanismo de bajo nivel <address>.call() (opens in a new tab). Ese mecanismo toma un bytes memory como entrada, y devuelve eso (así como un valor booleano) como salida.
// Primera llamada, la caché está vacía
_callInput = bytes.concat(
FOUR_PARAMS,
Es útil que el mismo contrato admita tanto funciones en caché (para llamadas directamente desde transacciones) como funciones sin caché (para llamadas desde otros contratos inteligentes). Para hacer eso, necesitamos seguir confiando en el mecanismo de Solidity para llamar a la función correcta, en lugar de poner todo en una función fallback (opens in a new tab). Hacer esto hace que la composabilidad sea mucho más fácil. Un solo byte sería suficiente para identificar la función en la mayoría de los casos, por lo que estamos desperdiciando tres bytes (16*3=48 de gas). Sin embargo, mientras escribo esto, esos 48 de gas cuestan 0.07 centavos, lo cual es un costo razonable para un código más simple y menos propenso a errores.
// Primer valor, agregarlo a la caché
cache.INTO_CACHE(),
bytes32(VAL_A),
El primer valor: Una bandera que dice que es un valor completo que necesita ser escrito en la caché, seguido de los 32 bytes del valor. Los otros tres valores son similares, excepto que VAL_B no se escribe en la caché y VAL_C es tanto el tercer parámetro como el cuarto.
.
.
.
);
(_success, _callOutput) = _cacheAddr.call(_callInput);
Aquí es donde realmente llamamos al contrato Cache.
assertEq(_success, true);
Esperamos que la llamada sea exitosa.
assertEq(cache.cacheRead(1), VAL_A);
assertEq(cache.cacheRead(2), VAL_C);
Comenzamos con una caché vacía y luego añadimos VAL_A seguido de VAL_C. Esperaríamos que el primero tuviera la clave 1, y el segundo tuviera la 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);
La salida son los cuatro parámetros. Aquí verificamos que sea correcta.
// Segunda llamada, podemos usar la caché
_callInput = bytes.concat(
FOUR_PARAMS,
// Primer valor en la caché
bytes1(0x01),
Las claves de caché por debajo de 16 son solo de un byte.
// Segundo valor, no agregarlo a la caché
cache.DONT_CACHE(),
bytes32(VAL_B),
// Tercer y cuarto valor, el mismo valor
bytes1(0x02),
bytes1(0x02)
);
.
.
.
} // testReadParam
Las pruebas después de la llamada son idénticas a las de después de la primera llamada.
function testEncodeVal() public {
Esta función es similar a testReadParam, excepto que en lugar de escribir los parámetros explícitamente usamos 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
La única prueba adicional en testEncodeVal() es verificar que la longitud de _callInput sea correcta. Para la primera llamada es 4+33*4. Para la segunda, donde cada valor ya está en la caché, es 4+1*4.
// Probar encodeVal cuando la clave es más de un solo byte
// Máximo tres bytes porque llenar la caché a cuatro bytes toma
// demasiado tiempo.
function testEncodeValBig() public {
// Poner una cantidad de valores en la caché.
// Para mantener las cosas simples, usar la clave n para el valor n.
for(uint i=1; i<0x1FFF; i++) {
cache.cacheWrite(i);
}
La función testEncodeVal anterior solo escribe cuatro valores en la caché, por lo que la parte de la función que trata con valores de múltiples bytes (opens in a new tab) no se comprueba. Pero ese código es complicado y propenso a errores.
La primera parte de esta función es un bucle que escribe todos los valores de 1 a 0x1FFF en la caché en orden, para que podamos codificar esos valores y saber a dónde van.
.
.
.
_callInput = bytes.concat(
FOUR_PARAMS,
cache.encodeVal(0x000F), // Un byte 0x0F
cache.encodeVal(0x0010), // Dos bytes 0x1010
cache.encodeVal(0x0100), // Dos bytes 0x1100
cache.encodeVal(0x1000) // Tres bytes 0x201000
);
Prueba valores de un byte, dos bytes y tres bytes. No probamos más allá de eso porque tomaría demasiado tiempo escribir suficientes entradas de pila (al menos 0x10000000, aproximadamente un cuarto de billón).
.
.
.
.
} // testEncodeValBig
// Probar que con un búfer excesivamente pequeño obtenemos un revertir
function testShortCalldata() public {
Prueba qué sucede en el caso anormal donde no hay suficientes parámetros.
.
.
.
(_success, _callOutput) = _cacheAddr.call(_callInput);
assertEq(_success, false);
} // testShortCalldata
Dado que se revierte, el resultado que deberíamos obtener es false.
// Llama con claves de caché que no están ahí
function testNoCacheKey() public {
.
.
.
_callInput = bytes.concat(
FOUR_PARAMS,
// Primer valor, añádelo a la caché
cache.INTO_CACHE(),
bytes32(VAL_A),
// Segundo valor
bytes1(0x0F),
bytes2(0x1234),
bytes11(0xA10102030405060708090A)
);
Esta función obtiene cuatro parámetros perfectamente legítimos, excepto que la caché está vacía, por lo que no hay valores allí para leer.
.
.
.
// Probar que con un búfer excesivamente largo todo funciona bien
function testLongCalldata() public {
address _cacheAddr = address(cache);
bool _success;
bytes memory _callInput;
bytes memory _callOutput;
// Primera llamada, la caché está vacía
_callInput = bytes.concat(
FOUR_PARAMS,
// Primer valor, agregarlo a la caché
cache.INTO_CACHE(), bytes32(VAL_A),
// Segundo valor, agregarlo a la caché
cache.INTO_CACHE(), bytes32(VAL_B),
// Tercer valor, agregarlo a la caché
cache.INTO_CACHE(), bytes32(VAL_C),
// Cuarto valor, agregarlo a la caché
cache.INTO_CACHE(), bytes32(VAL_D),
// Y otro valor para la "buena suerte"
bytes4(0x31112233)
);
Esta función envía cinco valores. Sabemos que el quinto valor se ignora porque no es una entrada de caché válida, lo que habría causado una reversión si no se hubiera incluido.
(_success, _callOutput) = _cacheAddr.call(_callInput);
assertEq(_success, true);
.
.
.
} // testLongCalldata
} // CacheTest
Una aplicación de muestra
Escribir pruebas en Solidity está muy bien, pero al final del día una aplicación descentralizada (dapp) necesita poder procesar solicitudes desde fuera de la cadena para ser útil. Este artículo demuestra cómo usar el almacenamiento en caché en una dapp con WORM, que significa "Escribir una vez, leer muchas" (Write Once, Read Many). Si una clave aún no está escrita, puede escribir un valor en ella. Si la clave ya está escrita, obtiene una reversión.
El contrato
Este es el contrato (opens in a new tab). En su mayoría repite lo que ya hemos hecho con Cache y CacheTest, por lo que solo cubrimos las partes que son interesantes.
import "./Cache.sol";
contract WORM is Cache {
La forma más fácil de usar Cache es heredarlo en nuestro propio contrato.
function writeEntryCached() external {
uint[] memory params = _readParams(2);
writeEntry(params[0], params[1]);
} // writeEntryCached
Esta función es similar a fourParam en CacheTest arriba. Debido a que no seguimos las especificaciones de la ABI, es mejor no declarar ningún parámetro en la función.
// Hacer que sea más fácil llamarnos
// Firma de la función para writeEntryCached(), cortesía de
// https://www.4byte.directory/signatures/?bytes4_signature=0xe4e4f2d3
bytes4 constant public WRITE_ENTRY_CACHED = 0xe4e4f2d3;
El código externo que llama a writeEntryCached necesitará construir manualmente los datos de llamada, en lugar de usar worm.writeEntryCached, porque no seguimos las especificaciones de la ABI. Tener este valor constante simplemente hace que sea más fácil escribirlo.
Tenga en cuenta que aunque definimos WRITE_ENTRY_CACHED como una variable de estado, para leerla externamente es necesario usar la función getter para ella, worm.WRITE_ENTRY_CACHED().
function readEntry(uint key) public view
returns (uint _value, address _writtenBy, uint _writtenAtBlock)
La función de lectura es un view, por lo que no requiere una transacción y no cuesta gas. Como resultado, no hay ningún beneficio en usar la caché para el parámetro. Con las funciones de vista es mejor usar el mecanismo estándar que es más simple.
El código de prueba
Este es el código de prueba para el contrato (opens in a new tab). De nuevo, veamos solo lo que es interesante.
function testWReadWrite() public {
worm.writeEntry(0xDEAD, 0x60A7);
vm.expectRevert(bytes("entry already written"));
worm.writeEntry(0xDEAD, 0xBEEF);
Así (vm.expectRevert) (opens in a new tab) es como especificamos en una prueba de Foundry que la siguiente llamada debería fallar, y la razón reportada para un fallo. Esto se aplica cuando usamos la sintaxis <contract>.<function name>() en lugar de construir los datos de llamada y llamar al contrato usando la interfaz de bajo nivel (<contract>.call(), etc.).
function testReadWriteCached() public {
uint cacheGoat = worm.cacheWrite(0x60A7);
Aquí usamos el hecho de que cacheWrite devuelve la clave de caché. Esto no es algo que esperaríamos usar en producción, porque cacheWrite cambia el estado, y por lo tanto solo se puede llamar durante una transacción. Las transacciones no tienen valores de retorno, si tienen resultados, se supone que esos resultados se emiten como eventos. Así que el valor de retorno de cacheWrite solo es accesible desde el código en cadena, y el código en cadena no necesita almacenamiento en caché de parámetros.
(_success,) = address(worm).call(_callInput);
Así es como le decimos a Solidity que aunque <contract address>.call() tiene dos valores de retorno, solo nos importa el primero.
(_success,) = address(worm).call(_callInput);
assertEq(_success, false);
Dado que usamos la función de bajo nivel <address>.call(), no podemos usar vm.expectRevert() y tenemos que mirar el valor booleano de éxito que obtenemos de la llamada.
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);
Esta es la forma en que verificamos que el código emite un evento correctamente (opens in a new tab) en Foundry.
El cliente
Una cosa que no se obtiene con las pruebas de Solidity es código JavaScript que se pueda copiar y pegar en su propia aplicación. La versión original de este tutorial implementó WORM en Optimism Goerli, que desde entonces ha sido retirada. Para ejecutar el cliente hoy, vuelva a implementar WORM en una red OP Stack compatible, como OP Sepolia (opens in a new tab), y luego use la dirección del contrato resultante en el cliente JavaScript.
Puede ver el código JavaScript para el cliente aquí (opens in a new tab). El repositorio de muestra se escribió para Optimism Goerli, por lo que antes de ejecutarlo, actualice el punto de conexión RPC y las URL del explorador en javascript/.env.example y javascript/index.js para su red de destino. Para usarlo:
-
Clone el repositorio git:
git clone https://github.com/qbzzt/20220915-all-you-can-cache.git -
Instale los paquetes necesarios:
cd javascript yarn -
Copie el archivo de configuración:
cp .env.example .env -
Edite
.envpara su configuración:Parámetro Valor MNEMONIC La frase mnemotécnica para una cuenta que tiene suficiente ETH para pagar una transacción. La documentación de faucet de Optimism (opens in a new tab) enumera los faucet actuales de la red de prueba. OPTIMISM_GOERLI_URL URL de RPC para la red donde vuelve a implementar WORM. Para OP Sepolia, use un punto de conexión RPC de OP Sepolia como https://sepolia.optimism.io, u otro punto de conexión de su proveedor. -
Ejecute
index.js.node index.jsEsta aplicación de muestra primero escribe una entrada en WORM, mostrando los datos de llamada y un enlace a la transacción en un explorador de bloques. Luego vuelve a leer esa entrada y muestra la clave que usa y los valores en la entrada (valor, número de bloque y autor).
La mayor parte del cliente es JavaScript normal de una aplicación descentralizada (dapp). Así que, de nuevo, solo repasaremos las partes interesantes.
.
.
.
const main = async () => {
const func = await worm.WRITE_ENTRY_CACHED()
// Necesita una nueva clave cada vez
const key = await worm.encodeVal(Number(new Date()))
Un slot dado solo se puede escribir una vez, por lo que usamos la marca de tiempo para asegurarnos de no reutilizar los slot.
const val = await worm.encodeVal("0x600D")
// Escribir una entrada
const calldata = func + key.slice(2) + val.slice(2)
Ethers espera que los datos de llamada sean una cadena hexadecimal, 0x seguida de un número par de dígitos hexadecimales. Como key y val comienzan con 0x, necesitamos eliminar esos encabezados.
const tx = await worm.populateTransaction.writeEntryCached()
tx.data = calldata
sentTx = await wallet.sendTransaction(tx)
Al igual que con el código de prueba de Solidity, no podemos llamar a una función en caché normalmente. En su lugar, necesitamos usar un mecanismo de nivel inferior.
.
.
.
// Leer la entrada recién escrita
const realKey = '0x' + key.slice(4) // eliminar la bandera FF
const entryRead = await worm.readEntry(realKey)
.
.
.
Para leer entradas podemos usar el mecanismo normal. No hay necesidad de usar el almacenamiento en caché de parámetros con las funciones view.
Conclusión
El código en este artículo es una prueba de concepto, el propósito es hacer que la idea sea fácil de entender. Para un sistema listo para producción, es posible que desee implementar alguna funcionalidad adicional:
-
Manejar valores que no son
uint256. Por ejemplo, cadenas de texto. -
En lugar de una caché global, tal vez tener un mapeo entre usuarios y cachés. Diferentes usuarios usan diferentes valores.
-
Los valores utilizados para las direcciones son distintos de los utilizados para otros fines. Podría tener sentido tener una caché separada solo para las direcciones.
-
Actualmente, las claves de caché están en un algoritmo de "el primero en llegar, la clave más pequeña". Los primeros dieciséis valores se pueden enviar como un solo byte. Los siguientes 4080 valores se pueden enviar como dos bytes. El siguiente millón de valores aproximadamente son de tres bytes, etc. Un sistema de producción debería mantener contadores de uso en las entradas de la caché y reorganizarlas para que los dieciséis valores más comunes sean de un byte, los siguientes 4080 valores más comunes de dos bytes, etc.
Sin embargo, esa es una operación potencialmente peligrosa. Imagine la siguiente secuencia de eventos:
-
Noam Naive llama a
encodeValpara codificar la dirección a la que quiere enviar tokens. Esa dirección es una de las primeras utilizadas en la aplicación, por lo que el valor codificado es 0x06. Esta es una funciónview, no una transacción, por lo que es entre Noam y el nodo que usa, y nadie más lo sabe. -
Owen Owner ejecuta la operación de reordenamiento de la caché. Muy pocas personas usan realmente esa dirección, por lo que ahora está codificada como 0x201122. A un valor diferente, 1018, se le asigna 0x06.
-
Noam Naive envía sus tokens a 0x06. Van a la dirección
0x0000000000000000000000000de0b6b3a7640000, y como nadie conoce la clave privada de esa dirección, simplemente se quedan atascados allí. Noam no está contento.
Hay formas de resolver este problema, y el problema relacionado de las transacciones que están en la mempool durante el reordenamiento de la caché, pero debe ser consciente de ello.
-
Demostré el almacenamiento en caché aquí con Optimism, porque soy un empleado de Optimism y este es el rollup que mejor conozco. Pero debería funcionar con cualquier rollup que cobre un costo mínimo por el procesamiento interno, de modo que, en comparación, escribir los datos de la transacción en la capa 1 (l1) sea el gasto principal.