Todo lo que pueda almacenar en una memoria caché
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:
Si el primer byte es
0xFF
, tome los siguientes 32 bytes como valor de parámetro y escríbalos 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é. He aquí algunos ejemplos:
Bytes en calldata Clave de caché 0x0F 0x0F 0x10,0x10 0x10 0x12,0xAC 0x02AC 0x2D,0xEA, 0xD6 0x0DEAD6
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: UNLICENSED2pragma solidity ^0.8.13;345contract Cache {67 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.
12 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 preserve2 // 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 } // cacheReadCopiar
Leer un valor de la caché.
1 // Write a value to the cache if it's not there already2 // Only public to enable the test to work3 function cacheWrite(uint _value) public returns (uint) {4 // If the value is already in the cache, return the current key5 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 can2 // hold is 0x0D followed by 15 0xFF's. If the cache length is already that3 // large, fail.4 // 1 2 3 4 5 6 7 8 9 A B C D E F5 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 key2 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 } // cacheWriteCopiar
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;34 require(length < 0x21,5 "_calldataVal length limit is 32 bytes");6 require(length + startByte <= msg.data.length,7 "_calldataVal trying to read beyond calldatasize");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 } // _calldataVal345 // Read a single parameter from the calldata, starting at _fromByte6 function _readParam(uint _fromByte) internal7 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 rest2 uint8 _firstByte;34 _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.
12 // Read the value, but do not write it to the cache3 if (_firstByte == uint8(DONT_CACHE))4 return(_fromByte+33, _calldataVal(_fromByte+1, 32));56 // Read the value, and write it to the cache7 if (_firstByte == uint8(INTO_CACHE)) {8 uint _param = _calldataVal(_fromByte+1, 32);9 cacheWrite(_param);10 return(_fromByte+33, _param);11 }1213 // If we got here it means that we need to read from the cache1415 // Number of extra bytes to read16 uint8 _extraBytes = _firstByte / 16;Mostrar todoCopiar
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);34 return (_fromByte+_extraBytes+1, cacheRead(_key));56 } // _readParam789 // Read n parameters (functions know how many parameters they expect)10 function _readParams(uint _paramNum) internal returns (uint[] memory) {Mostrar todoCopiar
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 read2 uint[] memory params = new uint[](_paramNum);34 // Parameters start at byte 4, before that it's the function signature5 uint _atByte = 4;67 for(uint i=0; i<_paramNum; i++) {8 (_atByte, params[i]) = _readParam(_atByte);9 }Mostrar todoCopiar
Lea los parámetros hasta obtener el número que necesite. Si nos pasamos el final de los Calldata, _readParams
esta revertirá.
12 return(params);3 } // readParams45 // For testing _readParams, test reading four parameters6 function fourParam() public7 returns (uint256,uint256,uint256,uint256)8 {9 uint[] memory params;10 params = _readParams(4);11 return (params[0], params[1], params[2], params[3]);12 } // fourParamMostrar todoCopiar
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];23 // The value isn't in the cache yet, add it4 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 byte2 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 0x1vvv2 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 and3 // simplicity.45 if (_key < 16*256**2)6 return bytes.concat(bytes3(uint24(_key) | (0x2 * 16 * 256**2)));7 if (_key < 16*256**3)8 return bytes.concat(bytes4(uint32(_key) | (0x3 * 16 * 256**3)));9 .10 .11 .12 if (_key < 16*256**14)13 return bytes.concat(bytes15(uint120(_key) | (0xE * 16 * 256**14)));14 if (_key < 16*256**15)15 return bytes.concat(bytes16(uint128(_key) | (0xF * 16 * 256**15)));Mostrar todoCopiar
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 } // encodeVal23} // CacheCopiar
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: UNLICENSED2pragma solidity ^0.8.13;34import "forge-std/Test.sol";567// 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;34 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 }45 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 } // testCaching345 // Cache the same value multiple times, ensure that the key stays6 // the same7 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 todoCopiar
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 } // testRepeatCachingCopiar
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 parameters2 // we sent out)3 function toUint256(bytes memory _bytes, uint256 _start) internal pure4 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;45 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
.
12 return tempUint;3 } // toUint25645 // Function signature for fourParams(), courtesy of6 // https://www.4byte.directory/signatures/?bytes4_signature=0x3edc1e6d7 bytes4 constant FOUR_PARAMS = 0x3edc1e6d;89 // Just some constant values to see we're getting the correct values back10 uint256 constant VAL_A = 0xDEAD60A7;11 uint256 constant VAL_B = 0xBEEF;12 uint256 constant VAL_C = 0x600D;13 uint256 constant VAL_D = 0x600D60A7;Mostrar todoCopiar
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 empty2 _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 cache2 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 cache2 _callInput = bytes.concat(3 FOUR_PARAMS,45 // First value in the Cache6 bytes1(0x01),Copiar
Las claves de caché por debajo de 16 son solo un byte.
1 // Second value, don't add it to the cache2 cache.DONT_CACHE(),3 bytes32(VAL_B),45 // Third and fourth values, same value6 bytes1(0x02),7 bytes1(0x02)8 );9 .10 .11 .12 } // testReadParamMostrar todoCopiar
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 } // testEncodeValMostrar todoCopiar
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 byte2 // Maximum three bytes because filling the cache to four bytes takes3 // 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 todoCopiar
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 .45 _callInput = bytes.concat(6 FOUR_PARAMS,7 cache.encodeVal(0x000F), // One byte 0x0F8 cache.encodeVal(0x0010), // Two bytes 0x10109 cache.encodeVal(0x0100), // Two bytes 0x110010 cache.encodeVal(0x1000) // Three bytes 0x20100011 );Mostrar todoCopiar
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 } // testEncodeValBig678 // Test what with an excessively small buffer we get a revert9 function testShortCalldata() public {Mostrar todoCopiar
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 } // testShortCalldataCopiar
Dado que se revierte, el resultado que deberíamos obtener es false
.
1 // Call with cache keys that aren't there2 function testNoCacheKey() public {3 .4 .5 .6 _callInput = bytes.concat(7 FOUR_PARAMS,89 // First value, add it to the cache10 cache.INTO_CACHE(),11 bytes32(VAL_A),1213 // Second value14 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 file5 function testLongCalldata() public {6 address _cacheAddr = address(cache);7 bool _success;8 bytes memory _callInput;9 bytes memory _callOutput;1011 // First call, the cache is empty12 _callInput = bytes.concat(13 FOUR_PARAMS,1415 // First value, add it to the cache16 cache.INTO_CACHE(), bytes32(VAL_A),1718 // Second value, add it to the cache19 cache.INTO_CACHE(), bytes32(VAL_B),2021 // Third value, add it to the cache22 cache.INTO_CACHE(), bytes32(VAL_C),2324 // Fourth value, add it to the cache25 cache.INTO_CACHE(), bytes32(VAL_D),2627 // And another value for "good luck"28 bytes4(0x31112233)29 );Mostrar todoCopiar
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 } // testLongCalldata78} // CacheTest9Mostrar todoCopiar
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";23contract 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 } // writeEntryCachedCopiar
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 us2 // Function signature for writeEntryCached(), courtesy of3 // https://www.4byte.directory/signatures/?bytes4_signature=0xe4e4f2d34 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 view2 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);34 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);23 .4 .5 .67 _callInput = bytes.concat(8 worm.WRITE_ENTRY_CACHED(), worm.encodeVal(a), worm.encodeVal(b));9 vm.expectEmit(true, true, false, false);10 emit EntryWritten(a, b);11 (_success,) = address(worm).call(_callInput);Mostrar todoCopiar
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:
Clone el repositorio de git:
1git clone https://github.com/qbzzt/20220915-all-you-can-cache.gitInstale los paquetes necesarios:
1cd javascript2yarnCopie el archivo de configuración:
1cp .env.example .envEdite
.env
en su configuración:Parámetro Valor MNEMONIC El 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_URL La URL a Optimism Goerli. La terminal pública, https://goerli.optimism.io
, tiene una tasa limitada pero suficiente para lo que necesitamos aquíEjecute
index.js
.1node index.jsEsta 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()67 // Need a new key every time8 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")23// Write an entry4const 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 = calldata34sentTx = 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 written5 const realKey = '0x' + key.slice(4) // remove the FF flag6 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:
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ónview
, no una transacción, así que está entre Noam y el nodo que usa, y nadie más lo sabe.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.
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), 15 de agosto de 2023