Ir al contenido principal

Todo lo que pueda almacenar en una memoria caché

capa 2guardar en cachéalmacenamiento
Intermedio
Ori Pomerantz
15 de septiembre de 2022
24 minuto leído minute read

Cuando se utilizan rollups (o acumulaciones), el coste de un byte en la transacción es mucho más caro que el coste de una ranura de almacenamiento. Por lo tanto, tiene sentido almacenar en caché la mayor cantidad de información posible en la 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 se pueda usar varias veces se almacenará en caché, y estará disponible para su uso (después de la primera vez) con un número mucho menor de bytes, y cómo cancelar el código de cadena que utiliza esa caché.

Si quiere omitir el artículo y ver el código fuente, lo encontrará aquí(opens in a new tab). La pila de desarrollo es Foundry(opens in a new tab).

Diseño general

En aras de la simplicidad, asumiremos que todos los parámetros de la transacción tienen una longitud de uint256, 32 bytes. Cuando recibamos una transacción, analizaremos cada parámetro de la siguiente manera:

  1. Si el primer byte es 0xFF, tome los siguientes 32 bytes como valor de parámetro y escríbalos en la caché.

  2. Si el primer byte es 0xFE, tome los siguientes 32 bytes como valor de parámetro, pero no lo escriba en la caché.

  3. 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é. He aquí algunos ejemplos:

    Bytes en calldataClave de caché
    0x0F0x0F
    0x10,0x100x10
    0x12,0xAC0x02AC
    0x2D,0xEA, 0xD60x0DEAD6

Manipulación de caché

La caché se implementa en Cache.sol(opens in a new tab). Vamos a repasarlo línea por línea.

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;
Copiar

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 ranuras de almacenamiento no utilizadas hasta el momento a un coste de 22.100 de gas cada una, por lo que lo hacemos opcional.

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

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.

1 // Location n has the value for key n+1, because we need to preserve
2 // zero as "not in the cache".
3 uint[] public key2val;
Copiar

Podemos usar una matriz para el mapeo de claves a valores porque asignamos las claves, y por simplicidad, lo hacemos secuencialmente.

1 function cacheRead(uint _key) public view returns (uint) {
2 require(_key <= key2val.length, "Reading uninitialize cache entry");
3 return key2val[_key-1];
4 } // cacheRead
Copiar

Leer un valor de la caché.

1 // Write a value to the cache if it's not there already
2 // Only public to enable the test to work
3 function cacheWrite(uint _value) public returns (uint) {
4 // If the value is already in the cache, return the current key
5 if (val2key[_value] != 0) {
6 return val2key[_value];
7 }
Copiar

No tiene sentido poner el mismo valor en la caché más de una vez. Si el valor ya está ahí, simplemente devuelva la clave existente.

1 // Since 0xFE is a special case, the largest key the cache can
2 // hold is 0x0D followed by 15 0xFF's. If the cache length is already that
3 // large, fail.
4 // 1 2 3 4 5 6 7 8 9 A B C D E F
5 require(key2val.length+1 < 0x0DFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF,
6 "cache overflow");
Copiar

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, tengo la edad suficiente para recordar que "640 kB siempre sería suficiente"(opens in a new tab). Esta prueba es muy barata.

1 // Write the value using the next key
2 val2key[_value] = key2val.length+1;
Copiar

Añada la búsqueda inversa (del valor a la clave).

1 key2val.push(_value);
Copiar

Añade la búsqueda hacia adelante (desde la clave hasta el valor). Debido a que asignamos valores secuencialmente, podemos añadirlos después del último valor de la matriz.

1 return key2val.length;
2 } // cacheWrite
Copiar

Devuelve la nueva longitud de key2val, que es la celda donde se almacena el nuevo valor.

1 function _calldataVal(uint startByte, uint length)
2 private pure returns (uint)
Copiar

Esta función lee un valor de Calldata de longitud arbitraria (hasta 32 bytes, el tamaño de la palabra).

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");
Copiar

Esta función es interna, por lo que si el resto del código está escrito correctamente, estas pruebas no son necesarias. Aunque tampoco es que cuesten tanto, así que podríamos tenerlas.

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

Este código está en Yul(opens in a new tab). Lee un valor de 32 bytes de los Calldata. Esto funciona incluso si los Calldata se detienen antes de startByte+32 porque el espacio no inicializado en EVM se considera cero.

1 _retVal = _retVal >> (256-length*8);
Copiar

No queremos necesariamente un valor de 32 bytes. Esto elimina el exceso de bytes.

1 return _retVal;
2 } // _calldataVal
3
4
5 // Read a single parameter from the calldata, starting at _fromByte
6 function _readParam(uint _fromByte) internal
7 returns (uint _nextByte, uint _parameterValue)
8 {
Copiar

Lea un solo parámetro de los Calldata. Tenga en cuenta que necesitamos devolver no solo el valor que leemos, sino también la ubicación del siguiente byte, porque la longitud de los parámetros puede variar de 1 byte a 33 bytes.

1 // The first byte tells us how to interpret the rest
2 uint8 _firstByte;
3
4 _firstByte = uint8(_calldataVal(_fromByte, 1));
Copiar

Solidity intenta reducir el número de errores al prohibir conversiones de tipo implícito potencialmente peligrosas(opens in a new tab). Una degradación, por ejemplo, de 256 bits a 8 bits, debe ser explícita.

1
2 // Read the value, but do not write it to the cache
3 if (_firstByte == uint8(DONT_CACHE))
4 return(_fromByte+33, _calldataVal(_fromByte+1, 32));
5
6 // Read the value, and write it to the cache
7 if (_firstByte == uint8(INTO_CACHE)) {
8 uint _param = _calldataVal(_fromByte+1, 32);
9 cacheWrite(_param);
10 return(_fromByte+33, _param);
11 }
12
13 // If we got here it means that we need to read from the cache
14
15 // Number of extra bytes to read
16 uint8 _extraBytes = _firstByte / 16;
Mostrar todo
Copiar

Toma el nibble(opens in a new tab) inferior y combínalo con los otros bytes para leer el valor de la caché.

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 // Read n parameters (functions know how many parameters they expect)
10 function _readParams(uint _paramNum) internal returns (uint[] memory) {
Mostrar todo
Copiar

Podríamos obtener el número de parámetros que tenemos de los propios Calldata, pero las funciones que nos invocan, saben cuántos parámetros esperan. Es más fácil dejar que nos lo digan.

1 // The parameters we read
2 uint[] memory params = new uint[](_paramNum);
3
4 // Parameters start at byte 4, before that it's the function signature
5 uint _atByte = 4;
6
7 for(uint i=0; i<_paramNum; i++) {
8 (_atByte, params[i]) = _readParam(_atByte);
9 }
Mostrar todo
Copiar

Lea los parámetros hasta obtener el número que necesite. Si nos pasamos el final de los Calldata, _readParams esta revertirá.

1
2 return(params);
3 } // readParams
4
5 // For testing _readParams, test reading four parameters
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
Mostrar todo
Copiar

Una de las principales ventajas de Foundry es que permite escribir las pruebas en Solidity (ver más adelante Probar la caché). 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, de manera que la prueba puede verificar si son correctos.

1 // Get a value, return bytes that will encode it (using the cache if possible)
2 function encodeVal(uint _val) public view returns(bytes memory) {
Copiar

encodeVal es una función que activa el código fuera de la cadena para ayudar a crear los Calldata que utilizan la caché. Esta recibe un único valor y devuelve los bytes que lo codifican. Esta función es una view, por lo que no requiere una transacción y cuando se activa externamente no cuesta gas.

1 uint _key = val2key[_val];
2
3 // The value isn't in the cache yet, add it
4 if (_key == 0)
5 return bytes.concat(INTO_CACHE, bytes32(_val));
Copiar

En la EVM se asume que todo el almacenamiento sin inicializar son ceros. Por tanto, 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 (por lo que estará en la caché la próxima vez), seguido de un valor real.

1 // If the key is <0x10, return it as a single byte
2 if (_key < 0x10)
3 return bytes.concat(bytes1(uint8(_key)));
Copiar

Los bytes individuales son los más fáciles. Solo usamos bytes.concat(opens in a new tab) para convertir un tipo bytes<n> en una matriz de bytes que puede tener cualquier longitud. A pesar del nombre, funciona bien cuando se le proporciona un solo argumento.

1 // Two byte value, encoded as 0x1vvv
2 if (_key < 0x1000)
3 return bytes.concat(bytes2(uint16(_key) | 0x1000));
Copiar

Cuando tenemos una clave que es inferior a 163, podemos expresarla en dos bytes. Primero convertimos _key, que es un valor de 256 bits, a un valor de 16 bits y usamos la lógica o para añadir el número de bytes adicionales al primer byte. Luego lo convertimos en un valor de bytes2, que se puede convertir en bytes.

1 // There is probably a clever way to do the following lines as a loop,
2 // but it's a view function so I'm optimizing for programmer time and
3 // simplicity.
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)));
Mostrar todo
Copiar

Los otros valores (3 bytes, 4 bytes, etc.) se manejan de la misma manera, solo con diferentes tamaños de campo.

1 // If we get here, something is wrong.
2 revert("Error in encodeVal, should not happen");
Copiar

Si hemos llegado aquí, quiere decir que tenemos una llave que no es inferior a 16*25615. Pero cacheWrite limita las claves, por lo que ni siquiera podemos llegar a 14*25616 (que tendría un primer byte de 0xFE, por lo que se vería como DONT_CACHE). Tampoco nos costaría mucho añadir una prueba en caso de que un futuro programador introduzca un error.

1 } // encodeVal
2
3} // Cache
Copiar

Probar la caché

Una de las ventajas de Foundry es que te permite escribir pruebas en Solidity(opens in a new tab), lo que facilita la escritura de pruebas unitarias. Las pruebas para la clase Cache son aquí(opens in a new tab). Dado que el código de prueba es repetitivo, como suelen ser las pruebas, este artículo solo explica las partes interesantes.

1// SPDX-License-Identifier: UNLICENSED
2pragma solidity ^0.8.13;
3
4import "forge-std/Test.sol";
5
6
7// Need to run `forge test -vv` for the console.
8import "forge-std/console.sol";
Copiar

Esto es solo la norma que es necesaria para usar el paquete de prueba y console.log.

1import "src/Cache.sol";
Copiar

Necesitamos saber el contrato que estamos probando.

1contract CacheTest is Test {
2 Cache cache;
3
4 function setUp() public {
5 cache = new Cache();
6 }
Copiar

La función setUp se activa antes de cada prueba. En este caso, solo creamos una nueva caché, para que nuestras pruebas no se afecten entre sí.

1 function testCaching() public {
Copiar

Las pruebas son funciones cuyos nombres comienzan por test. Esta función comprueba la funcionalidad básica de la caché, escribiendo valores y leyéndolos de nuevo.

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);
Copiar

Así es como se hacen las pruebas reales, utilizando las funciones assert...(opens in a new tab). En este caso, comprobamos que el valor que escribimos es el mismo que leemos. Podemos descartar el resultado de cache.cacheWrite porque sabemos que las claves de caché se asignan de forma lineal.

1 }
2 } // testCaching
3
4
5 // Cache the same value multiple times, ensure that the key stays
6 // the same
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 }
Mostrar todo
Copiar

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).

1 for(uint i=1; i<100; i+=3) {
2 uint _key = cache.cacheWrite(i);
3 assertEq(_key, i);
4 }
5 } // testRepeatCaching
Copiar

En teoría, podría haber un error que no afecte a las escrituras consecutivas en caché. Así que aquí hacemos algunas escrituras que no son consecutivas y vemos que los valores aún no se han reescrito.

1 // Read a uint from a memory buffer (to make sure we get back the parameters
2 // we sent out)
3 function toUint256(bytes memory _bytes, uint256 _start) internal pure
4 returns (uint256)
Copiar

Lee una palabra de 256 bits desde un búfer de bytes de memoria. Esta función de utilidad nos permite verificar que recibimos los resultados correctos cuando ejecutamos una activación de la función que utiliza la caché.

1 {
2 require(_bytes.length >= _start + 32, "toUint256_outOfBounds");
3 uint256 tempUint;
4
5 assembly {
6 tempUint := mload(add(add(_bytes, 0x20), _start))
7 }
Copiar

Yul no admite estructuras de datos más allá de uint256, por lo que cuando usted se refiere a una estructura de datos más sofisticada, como el búfer de memoria _bytes, se obtiene la dirección de esa estructura. Solidity almacena valores de bytes de memoria como una palabra de 32 bytes que contiene la longitud, seguida de los bytes reales, por lo que para obtener el número de bytes _start necesitamos calcular _bytes+32+_start.

1
2 return tempUint;
3 } // toUint256
4
5 // Function signature for fourParams(), courtesy of
6 // https://www.4byte.directory/signatures/?bytes4_signature=0x3edc1e6d
7 bytes4 constant FOUR_PARAMS = 0x3edc1e6d;
8
9 // Just some constant values to see we're getting the correct values back
10 uint256 constant VAL_A = 0xDEAD60A7;
11 uint256 constant VAL_B = 0xBEEF;
12 uint256 constant VAL_C = 0x600D;
13 uint256 constant VAL_D = 0x600D60A7;
Mostrar todo
Copiar

Algunas de las constantes que necesitamos probar.

1 function testReadParam() public {
Copiar

Llame a fourParams(), una función que utiliza readParams, para probar que podemos leer los parámetros correctamente.

1 address _cacheAddr = address(cache);
2 bool _success;
3 bytes memory _callInput;
4 bytes memory _callOutput;
Copiar

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 una memoria de bytes como entrada, y la devuelve (así como un valor booleano) como salida.

1 // First call, the cache is empty
2 _callInput = bytes.concat(
3 FOUR_PARAMS,
Copiar

Es útil que el mismo contrato admita tanto funciones en caché (para activaciones directamente desde transacciones) como funciones no en caché (para activaciones desde otros contratos inteligentes). Para ell, tenemos que seguir confiando en el mecanismo Solidity para activar la función correcta, en lugar de poner todo en una función fallback(opens in a new tab). Esto hace que la composición 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 gas). Sin embargo, mientras escribo esto, esos 48 de gas cuestan 0,07 centavos, que es un coste razonable de un código más simple y menos propenso a errores.

1 // First value, add it to the cache
2 cache.INTO_CACHE(),
3 bytes32(VAL_A),
Copiar

El primer valor: una bandera que dice que es un valor completo que debe escribirse en la caché, seguido de los 32 bytes del valor. Los otros tres valores son similares, con la salvedad de que VAL_B no están escritos en la caché y VAL_C es tanto el tercer parámetro como el cuarto.

1 .
2 .
3 .
4 );
5 (_success, _callOutput) = _cacheAddr.call(_callInput);
Copiar

Aquí es donde realmente llamamos al contrato Cache.

1 assertEq(_success, true);
Copiar

Esperamos que la activación tenga éxito.

1 assertEq(cache.cacheRead(1), VAL_A);
2 assertEq(cache.cacheRead(2), VAL_C);
Copiar

Comenzamos con una caché vacía y luego añadimos VAL_A seguido de VAL_C. Esperaríamos que la primera tuviera la clave 1 y que la segunda tuviera 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);

La salida son los cuatro parámetros. Aquí verificamos que es correcto.

1 // Second call, we can use the cache
2 _callInput = bytes.concat(
3 FOUR_PARAMS,
4
5 // First value in the Cache
6 bytes1(0x01),
Copiar

Las claves de caché por debajo de 16 son solo un byte.

1 // Second value, don't add it to the cache
2 cache.DONT_CACHE(),
3 bytes32(VAL_B),
4
5 // Third and fourth values, same value
6 bytes1(0x02),
7 bytes1(0x02)
8 );
9 .
10 .
11 .
12 } // testReadParam
Mostrar todo
Copiar

Las pruebas después de la activación son idénticas a las posteriores a la primera activación.

1 function testEncodeVal() public {
Copiar

Esta función es similar a testReadParam, salvo que en lugar de escribir los parámetros explícitamente usamos 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
Mostrar todo
Copiar

La única prueba adicional en testEncodeVal() es verificar que la longitud de _callInput es correcta. Para la primera activación es 4+33*4. Para la segunda, donde cada valor ya está en la caché, es 4+1*4.

1 // Test encodeVal when the key is more than a single byte
2 // Maximum three bytes because filling the cache to four bytes takes
3 // too long.
4 function testEncodeValBig() public {
5 // Put a number of values in the cache.
6 // To keep things simple, use key n for value n.
7 for(uint i=1; i<0x1FFF; i++) {
8 cache.cacheWrite(i);
9 }
Mostrar todo
Copiar

La función testEncodeVal anterior solo escribe cuatro valores en la caché, por lo que la parte de la función que se ocupa de los valores de varios bytes(opens in a new tab) no está marcada. 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, por lo que podremos codificar esos valores y saber a dónde van.

1 .
2 .
3 .
4
5 _callInput = bytes.concat(
6 FOUR_PARAMS,
7 cache.encodeVal(0x000F), // One byte 0x0F
8 cache.encodeVal(0x0010), // Two bytes 0x1010
9 cache.encodeVal(0x0100), // Two bytes 0x1100
10 cache.encodeVal(0x1000) // Three bytes 0x201000
11 );
Mostrar todo
Copiar

Pruebe con los valores de un byte, dos bytes y tres bytes. No probamos más allá de eso, porque llevaría demasiado tiempo escribir suficientes entradas de pila (al menos 0 x 10000000, aproximadamente un cuarto de mil millones).

1 .
2 .
3 .
4 .
5 } // testEncodeValBig
6
7
8 // Test what with an excessively small buffer we get a revert
9 function testShortCalldata() public {
Mostrar todo
Copiar

Pruebe lo que sucede en el caso anormal en el que no haya suficientes parámetros.

1 .
2 .
3 .
4 (_success, _callOutput) = _cacheAddr.call(_callInput);
5 assertEq(_success, false);
6 } // testShortCalldata
Copiar

Dado que se revierte, el resultado que deberíamos obtener es false.

1 // Call with cache keys that aren't there
2 function testNoCacheKey() public {
3 .
4 .
5 .
6 _callInput = bytes.concat(
7 FOUR_PARAMS,
8
9 // First value, add it to the cache
10 cache.INTO_CACHE(),
11 bytes32(VAL_A),
12
13 // Second value
14 bytes1(0x0F),
15 bytes2(0x1234),
16 bytes11(0xA10102030405060708090A)
17 );
Mostrar todo

Esta función obtiene cuatro parámetros perfectamente legítimos, excepto que la caché está vacía, por lo que no hay valores para leer.

1 .
2 .
3 .
4 // Test what with an excessively long buffer everything works file
5 function testLongCalldata() public {
6 address _cacheAddr = address(cache);
7 bool _success;
8 bytes memory _callInput;
9 bytes memory _callOutput;
10
11 // First call, the cache is empty
12 _callInput = bytes.concat(
13 FOUR_PARAMS,
14
15 // First value, add it to the cache
16 cache.INTO_CACHE(), bytes32(VAL_A),
17
18 // Second value, add it to the cache
19 cache.INTO_CACHE(), bytes32(VAL_B),
20
21 // Third value, add it to the cache
22 cache.INTO_CACHE(), bytes32(VAL_C),
23
24 // Fourth value, add it to the cache
25 cache.INTO_CACHE(), bytes32(VAL_D),
26
27 // And another value for "good luck"
28 bytes4(0x31112233)
29 );
Mostrar todo
Copiar

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.

1 (_success, _callOutput) = _cacheAddr.call(_callInput);
2 assertEq(_success, true);
3 .
4 .
5 .
6 } // testLongCalldata
7
8} // CacheTest
9
Mostrar todo
Copiar

Una aplicación de muestra

Escribir pruebas en Solidity está muy bien, pero al fin y al cabo una DApp tiene que ser capaz de procesar solicitudes de fuera de la cadena para ser útil. Este artículo muestra cómo usar el almacenamiento en caché en una DApp con WORM, que significa «Write Once, Read Many». Si aún no se ha escrito una clave, puede escribirle un valor. Si la clave ya está escrita, se 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.

1import "./Cache.sol";
2
3contract WORM is Cache {
Copiar

La forma más fácil de usar Cache es heredarlo en nuestro propio contrato.

1 function writeEntryCached() external {
2 uint[] memory params = _readParams(2);
3 writeEntry(params[0], params[1]);
4 } // writeEntryCached
Copiar

Esta función es similar a fourParam en CacheTest anterior. Debido a que no seguimos las especificaciones de ABI, es mejor no declarar ningún parámetro en la función.

1 // Make it easier to call us
2 // Function signature for writeEntryCached(), courtesy of
3 // https://www.4byte.directory/signatures/?bytes4_signature=0xe4e4f2d3
4 bytes4 constant public WRITE_ENTRY_CACHED = 0xe4e4f2d3;
Copiar

El código externo que activa a writeEntryCached tendrá que construir manualmente los Calldata, en lugar de usar worm.writeEntryCached, porque no seguimos las especificaciones de ABI. Tener este valor constante hace que sea más fácil escribirlo.

Tenga en cuenta que a pesar de que 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().

1 function readEntry(uint key) public view
2 returns (uint _value, address _writtenBy, uint _writtenAtBlock)
Copiar

La función de lectura es una 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, lo mejor es utilizar 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). Una vez más, echemos un vistazo a lo que es interesante.

1 function testWReadWrite() public {
2 worm.writeEntry(0xDEAD, 0x60A7);
3
4 vm.expectRevert(bytes("entry already written"));
5 worm.writeEntry(0xDEAD, 0xBEEF);
Copiar

Esto (vm.expectRevert)(opens in a new tab) es como especificamos en una prueba de Foundry que la siguiente activación debe fallar, e informamos de la razón del fallo. Esto se aplica cuando usamos la sintaxis <contract>.<function name>() en lugar de construir los Calldata y activar el contrato utilizando la interfaz de bajo nivel (<contract>.call(), etc.).

1 function testReadWriteCached() public {
2 uint cacheGoat = worm.cacheWrite(0x60A7);
Copiar

Aquí nos basamos en que cacheWrite devuelve la clave de la caché. Nno es algo que esperábamos usar en la 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. Por lo tanto, el valor de retorno cacheWrite solo es accesible desde el código en cadena, y el código en cadena no necesita almacenamiento en caché de parámetros.

1 (_success,) = address(worm).call(_callInput);
Copiar

Así es como le decimos a Solidity que, si bien <contract address>.call() tiene dos valores de retorno, solo nos importa el primero.

1 (_success,) = address(worm).call(_callInput);
2 assertEq(_success, false);
Copiar

Dado que usamos la función de bajo nivel <address>.call(), no podemos usar vm.expectRevert() y tenemos que mirar el valor de éxito booleano que obtenemos de la activación.

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);
Mostrar todo
Copiar

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 consigue con las pruebas de Solidity es un código JavaScript para cortar y pegar en su propia aplicación. Para escribir ese código, implementé WORM en Optimism Goerli(opens in a new tab), Optimism(opens in a new tab) nueva red de prueba. Está en la dirección 0xd34335b1d818cee54e3323d3246bd31d94e6a78a(opens in a new tab).

Puede ver el código JavaScript para el cliente aquí(opens in a new tab). Para usarlo:

  1. Clone el repositorio de git:

    1git clone https://github.com/qbzzt/20220915-all-you-can-cache.git
  2. Instale los paquetes necesarios:

    1cd javascript
    2yarn
  3. Copie el archivo de configuración:

    1cp .env.example .env
  4. Edite .env en su configuración:

    ParámetroValor
    MNEMONICEl mnemotécnico de una cuenta que tiene suficiente ETH para pagar una transacción. Puede obtener ETH gratis para la red Optimism Goerli aquí(opens in a new tab).
    OPTIMISM_GOERLI_URLLa URL a Optimism Goerli. La terminal pública, https://goerli.optimism.io, tiene una tasa limitada pero suficiente para lo que necesitamos aquí
  5. Ejecute index.js.

    1node index.js

    Esta aplicación de ejemplo escribe primero una entrada en WORM, mostrando los Calldata y un enlace a la transacción en Etherscan. Luego lee esa entrada y muestra la clave que utiliza y los valores de la entrada (valor, número de bloque y autor).

La mayor parte del cliente es una DApp JavaScript normal. Así que, de nuevo, solo repasamos las partes interesantes.

1.
2.
3.
4const main = async () => {
5 const func = await worm.WRITE_ENTRY_CACHED()
6
7 // Need a new key every time
8 const key = await worm.encodeVal(Number(new Date()))

Una ranura determinada solo se puede escribir una vez, por lo que usamos la marca de tiempo para asegurarnos de no reutilizar las ranuras.

1const val = await worm.encodeVal("0x600D")
2
3// Write an entry
4const calldata = func + key.slice(2) + val.slice(2)

Ethers espera que los datos de la activación sean una cadena hexadecimal, 0x seguida de un número par de dígitos hexadecimales. Como key y val comienzan por 0x, tenemos que eliminar esos encabezados.

1const tx = await worm.populateTransaction.writeEntryCached()
2tx.data = calldata
3
4sentTx = await wallet.sendTransaction(tx)

Al igual que con el código de prueba de Solidity, no podemos activar a una función en caché normalmente. En su lugar, necesitamos usar un mecanismo de nivel inferior.

1 .
2 .
3 .
4 // Read the entry just written
5 const realKey = '0x' + key.slice(4) // remove the FF flag
6 const entryRead = await worm.readEntry(realKey)
7 .
8 .
9 .
Mostrar todo

Para leer las entradas podemos usar el mecanismo normal. No es necesario usar el almacenamiento en caché de parámetros con las funciones view.

Conclusión

El código de 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 la producción, es posible que se deseen implementar algunas funciones adicionales:

  • Manejar valores que no son uint256. Por ejemplo, cadenas.

  • En lugar de una caché global, tal vez tenga una asignación entre los usuarios y las cachés. Diferentes usuarios usan diferentes valores.

  • Los valores utilizados para las direcciones son distintos de los utilizados para otros fines. Tener una caché separada solo para las direcciones podría ser oportuno.

  • Actualmente, las claves de caché están en un algoritmo de «el primero en llegar se lleva la clave más pequeña». Los primeros dieciséis valores se pueden enviar como un solo byte. Los siguientes 4.080 valores se pueden enviar como dos bytes. Los siguientes valores de cerca de un millón son de tres bytes, etc. Un sistema de producción debe mantener los contadores de uso en las entradas de la caché y reorganizarlos de modo que los dieciséis valores más comunes sean de un byte, los siguientes 4.080 valores más comunes de dos bytes, etc.

    Sin embargo, esta es una operación potencialmente peligrosa. Imagine la siguiente secuencia de eventos:

    1. Noam Naive activa encodeVal para codificar la dirección a la que quiere enviar tókenes. Esa dirección es una de las primeras utilizadas en la aplicación, por lo que el valor codificado es 0 x 06. Esta es una función view, no una transacción, así que está entre Noam y el nodo que usa, y nadie más lo sabe.

    2. Owen Owner ejecuta la operación de reordenación de la caché. Muy pocas personas realmente usan esa dirección, por lo que ahora está codificada como 0 x 201122. Un valor diferente, 1018, se asigna 0 x 06.

    3. Noam Naive envía sus fichas a 0 x 06. Van a la dirección 0x0000000000000000000000000de0b6b3a7640000, y como nadie conoce la clave privada de esa dirección, están atrapados allí. Noam no está contento al respecto.

    Hay formas de resolver este problema y el problema relacionado con las transacciones que están en la zona de espera durante el reorden de la caché, pero debe ser consciente de ello.

He demostrado 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 coste mínimo por el procesamiento interno, de modo que, en comparación, escribir los datos de la transacción en L1 sea el mayor gasto.

Última edición: @nhsz(opens in a new tab), 21 de febrero de 2024

¿Le ha resultado útil este tutorial?