Todo lo que puede almacenar en caché
Al usar rollups, el costo de un byte en la transacción es mucho más caro que el costo de una ranura de almacenamiento. Por lo tanto, tiene sentido almacenar en caché tanta información como sea 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 pueda usarse 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 escribir código fuera de la cadena que use esta caché.
Si quiere saltarse el artículo y ver directamente el código fuente, está aquíopens in a new tab. La pila de desarrollo es Foundryopens in a new tab.
Diseño general
En aras de la simplicidad, supondremos que todos los parámetros de la transacción son uint256 y tienen 32 bytes de longitud. 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 un valor de parámetro y escríbalo en la caché. -
Si el primer byte es
0xFE, tome los siguientes 32 bytes como un 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é. Estos son algunos ejemplos:
Bytes en calldata Clave de caché 0x0F 0x0F 0x10,0x10 0x10 0x12,0xAC 0x02AC 0x2D,0xEA, 0xD6 0x0DEAD6
Manipulación de la caché
La caché está implementada en Cache.solopens 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;Estas constantes se utilizan para interpretar los casos especiales en los que proporcionamos toda la información y queremos que se escriba o no en la caché. Escribir en la caché requiere dos operaciones SSTOREopens in a new tab en ranuras de almacenamiento no utilizadas previamente, con un costo de 22 100 de gas cada una, por lo que lo hacemos opcional.
12 mapping(uint => uint) public val2key;Un mapeoopens 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 // La ubicación n tiene el valor para la clave n+1, porque necesitamos preservar2 // el cero como «no está en la caché».3 uint[] public key2val;Podemos usar una matriz para el mapeo de claves a valores porque asignamos las claves y, para simplificar, lo hacemos secuencialmente.
1 function cacheRead(uint _key) public view returns (uint) {2 require(_key <= key2val.length, "Lectura de entrada de caché sin inicializar");3 return key2val[_key-1];4 } // cacheReadLeer un valor de la caché.
1 // Escribir un valor en la caché si no existe ya2 // Público solo para permitir que la prueba funcione3 function cacheWrite(uint _value) public returns (uint) {4 // Si el valor ya está en la caché, devolver la clave actual5 if (val2key[_value] != 0) {6 return val2key[_value];7 }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 // Como 0xFE es un caso especial, la clave más grande que la caché puede2 // contener es 0x0D seguida de 15 0xFF. Si la longitud de la caché ya es3 // tan grande, falla.4 // 1 2 3 4 5 6 7 8 9 A B C D E F5 require(key2val.length+1 < 0x0DFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF,6 "desbordamiento de caché");No creo que lleguemos a tener una caché tan grande (aproximadamente 1,8*1037 entradas, lo que requeriría unos 1027 TB para almacenarse). Sin embargo, soy lo suficientemente mayor para recordar «640 kB serían siempre suficientes»opens in a new tab. Esta prueba es muy barata.
1 // Escribir el valor usando la siguiente clave2 val2key[_value] = key2val.length+1;Añada la búsqueda inversa (del valor a la clave).
1 key2val.push(_value);Añada la búsqueda hacia adelante (de la clave al valor). Como asignamos los valores de forma secuencial, podemos añadirlo después del último valor de la matriz.
1 return key2val.length;2 } // cacheWriteDevuelve 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)Esta función lee un valor de los calldata de longitud arbitraria (hasta 32 bytes, el tamaño de la palabra).
1 {2 uint _retVal;34 require(length < 0x21,5 "El límite de longitud de _calldataVal es de 32 bytes");6 require(length + startByte <= msg.data.length,7 "_calldataVal intenta leer más allá de 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 es mejor tenerlas.
1 assembly {2 _retVal := calldataload(startByte)3 }Este código está en Yulopens 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 la EVM se considera cero.
1 _retVal = _retVal >> (256-length*8);No queremos necesariamente un valor de 32 bytes. Esto elimina el exceso de bytes.
1 return _retVal;2 } // _calldataVal345 // Leer un solo parámetro de los calldata, a partir de _fromByte6 function _readParam(uint _fromByte) internal7 returns (uint _nextByte, uint _parameterValue)8 {Leer un único parámetro de los calldata. Tenga en cuenta que no solo necesitamos devolver el valor que leemos, sino también la ubicación del siguiente byte, porque los parámetros pueden tener una longitud de entre 1 y 33 bytes.
1 // El primer byte nos dice cómo interpretar el resto2 uint8 _firstByte;34 _firstByte = uint8(_calldataVal(_fromByte, 1));Solidity intenta reducir el número de errores prohibiendo conversiones de tipo implícitasopens in a new tab potencialmente peligrosas. Una degradación, por ejemplo de 256 bits a 8 bits, debe ser explícita.
12 // Leer el valor, pero no escribirlo en la caché3 if (_firstByte == uint8(DONT_CACHE))4 return(_fromByte+33, _calldataVal(_fromByte+1, 32));56 // Leer el valor y escribirlo en la caché7 if (_firstByte == uint8(INTO_CACHE)) {8 uint _param = _calldataVal(_fromByte+1, 32);9 cacheWrite(_param);10 return(_fromByte+33, _param);11 }1213 // Si hemos llegado aquí, significa que tenemos que leer de la caché1415 // Número de bytes extra para leer16 uint8 _extraBytes = _firstByte / 16;Mostrar todoTome el nibbleopens in a new tab inferior y combínelo 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 // Leer n parámetros (las funciones saben cuántos parámetros esperan)10 function _readParams(uint _paramNum) internal returns (uint[] memory) {Mostrar todoPodríamos obtener el número de parámetros que tenemos de los propios calldata, pero las funciones que nos llaman saben cuántos parámetros esperan. Es más fácil dejar que nos lo digan.
1 // Los parámetros que leemos2 uint[] memory params = new uint[](_paramNum);34 // Los parámetros empiezan en el byte 4, antes de eso está la firma de la función5 uint _atByte = 4;67 for(uint i=0; i<_paramNum; i++) {8 (_atByte, params[i]) = _readParam(_atByte);9 }Mostrar todoLea los parámetros hasta que tenga el número que necesita. Si pasamos del final de los calldata, _readParams revertirá la llamada.
12 return(params);3 } // readParams45 // Para probar _readParams, pruebe la lectura de cuatro parámetros6 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 todoUna gran ventaja de Foundry es que permite escribir pruebas en Solidity (véase Probar la caché más abajo). Esto facilita mucho las pruebas unitarias. Esta es una función que lee cuatro parámetros y los devuelve para que la prueba pueda verificar que son correctos.
1 // Obtener un valor, devolver los bytes que lo codificarán (usando la caché si es posible)2 function encodeVal(uint _val) public view returns(bytes memory) {encodeVal es una función a la que llama el código fuera de la cadena para ayudar a crear los calldata que usan la caché. 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 la llama externamente, no cuesta nada de gas.
1 uint _key = val2key[_val];23 // El valor aún no está en la caché, lo añade4 if (_key == 0)5 return bytes.concat(INTO_CACHE, bytes32(_val));En la EVM, se asume que todo el almacenamiento no inicializado es cero. 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 (por lo que se almacenará en caché la próxima vez), seguido del valor real.
1 // Si la clave es <0x10, la devuelve como un solo byte2 if (_key < 0x10)3 return bytes.concat(bytes1(uint8(_key)));Los bytes únicos son los más fáciles. Solo usamos bytes.concatopens 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 proporciona un solo argumento.
1 // Valor de dos bytes, codificado como 0x1vvv2 if (_key < 0x1000)3 return bytes.concat(bytes2(uint16(_key) | 0x1000));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 un OR lógico para añadir el número de bytes adicionales al primer byte. Luego lo convertimos en un valor bytes2, que se puede convertir a bytes.
1 // Probablemente haya una forma inteligente de hacer las siguientes líneas como un bucle,2 // pero es una función de vista, así que estoy optimizando para el tiempo del programador y3 // la simplicidad.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 todoLos otros valores (3 bytes, 4 bytes, etc.) se manejan de la misma manera, solo que con tamaños de campo diferentes.
1 // Si llegamos aquí, algo anda mal.2 revert("Error en encodeVal, no debería ocurrir");Si llegamos aquí, significa que obtuvimos una clave 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 parecería DONT_CACHE). Pero no nos cuesta mucho añadir una prueba en caso de que un futuro programador introduzca un error.
1 } // encodeVal23} // CacheProbar la caché
Una de las ventajas de Foundry es que le permite escribir pruebas en Solidityopens 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. 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// Es necesario ejecutar `forge test -vv` para la consola.8import "forge-std/console.sol";Esto es solo un texto repetitivo que es necesario para usar el paquete de prueba y console.log.
1import "src/Cache.sol";Necesitamos saber el contrato que estamos probando.
1contract CacheTest is Test {2 Cache cache;34 function setUp() public {5 cache = new Cache();6 }La función setUp se llama 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 {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.
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);Así es como se realizan las pruebas reales, usando las 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.
1 }2 } // testCaching345 // Almacenar en caché el mismo valor varias veces, asegurarse de que la clave siga siendo6 // la misma7 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 todoPrimero 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 se realizó).
1 for(uint i=1; i<100; i+=3) {2 uint _key = cache.cacheWrite(i);3 assertEq(_key, i);4 }5 } // testRepeatCachingEn 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 han reescrito.
1 // Leer un uint desde un búfer de memoria (para asegurarnos de que recuperamos los parámetros2 // que enviamos)3 function toUint256(bytes memory _bytes, uint256 _start) internal pure4 returns (uint256)Lee una palabra de 256 bits desde un búfer de memoria de bytes. Esta función de utilidad nos permite verificar que recibimos los resultados correctos cuando ejecutamos una llamada de 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 }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 de 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.
12 return tempUint;3 } // toUint25645 // Firma de la función para fourParams(), cortesía de6 // https://www.4byte.directory/signatures/?bytes4_signature=0x3edc1e6d7 bytes4 constant FOUR_PARAMS = 0x3edc1e6d;89 // Solo algunos valores constantes para ver que estamos obteniendo los valores correctos10 uint256 constant VAL_A = 0xDEAD60A7;11 uint256 constant VAL_B = 0xBEEF;12 uint256 constant VAL_C = 0x600D;13 uint256 constant VAL_D = 0x600D60A7;Mostrar todoAlgunas constantes que necesitamos para las pruebas.
1 function testReadParam() public {Llame a fourParams(), una función que usa 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;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 lo devuelve (así como un valor booleano) como salida.
1 // Primera llamada, la caché está vacía2 _callInput = bytes.concat(3 FOUR_PARAMS,Es útil que el mismo contrato admita tanto funciones en caché (para llamadas directamente desde transacciones) como funciones no cacheadas (para llamadas desde otros contratos inteligentes). Para ello, debemos seguir confiando en el mecanismo de Solidity para llamar a la función correcta, en lugar de poner todo en una función fallbackopens in a new tab. Hacer esto facilita mucho la componibilidad. 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, que es un costo razonable para un código más simple y menos propenso a errores.
1 // Primer valor, agréguelo a la caché2 cache.INTO_CACHE(),3 bytes32(VAL_A),El primer valor: una bandera que indica que es un valor completo que debe escribirse 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 como el cuarto parámetro.
1 .2 .3 .4 );5 (_success, _callOutput) = _cacheAddr.call(_callInput);Aquí es donde realmente llamamos al contrato de Cache.
1 assertEq(_success, true);Esperamos que la llamada tenga éxito.
1 assertEq(cache.cacheRead(1), VAL_A);2 assertEq(cache.cacheRead(2), VAL_C);Empezamos 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 la segunda tuviera la 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 // Segunda llamada, podemos usar la caché2 _callInput = bytes.concat(3 FOUR_PARAMS,45 // Primer valor en la caché6 bytes1(0x01),Las claves de caché por debajo de 16 son de solo un byte.
1 // Segundo valor, no añadirlo a la caché2 cache.DONT_CACHE(),3 bytes32(VAL_B),45 // Tercer y cuarto valor, mismo valor6 bytes1(0x02),7 bytes1(0x02)8 );9 .10 .11 .12 } // testReadParamMostrar todoLas pruebas después de la llamada son idénticas a las posteriores a la primera llamada.
1 function testEncodeVal() public {Esta función es similar a testReadParam, excepto 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 todoLa única prueba adicional en testEncodeVal() es verificar que la longitud de _callInput es correcta. Para la primera llamada es 4+33_4. Para la segunda, donde cada valor ya está en la caché, es 4+1_4.
1 // Probar encodeVal cuando la clave es de más de un solo byte2 // Máximo de tres bytes porque llenar la caché a cuatro bytes lleva3 // demasiado tiempo.4 function testEncodeValBig() public {5 // Poner una serie de valores en la caché.6 // Para simplificar, use la clave n para el valor n.7 for(uint i=1; i<0x1FFF; i++) {8 cache.cacheWrite(i);9 }Mostrar todoLa 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 varios bytesopens 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.
1 .2 .3 .45 _callInput = bytes.concat(6 FOUR_PARAMS,7 cache.encodeVal(0x000F), // Un byte 0x0F8 cache.encodeVal(0x0010), // Dos bytes 0x10109 cache.encodeVal(0x0100), // Dos bytes 0x110010 cache.encodeVal(0x1000) // Tres bytes 0x20100011 );Mostrar todoPruebe 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 0x10000000, aproximadamente un cuarto de mil millones).
1 .2 .3 .4 .5 } // testEncodeValBig678 // Probar qué ocurre si, con un búfer excesivamente pequeño, obtenemos una reversión9 function testShortCalldata() public {Mostrar todoPruebe qué ocurre en el caso anómalo en el que no hay suficientes parámetros.
1 .2 .3 .4 (_success, _callOutput) = _cacheAddr.call(_callInput);5 assertEq(_success, false);6 } // testShortCalldataComo se revierte, el resultado que deberíamos obtener es false.
1 // Llamar con claves de caché que no están ahí2 function testNoCacheKey() public {3 .4 .5 .6 _callInput = bytes.concat(7 FOUR_PARAMS,89 // Primer valor, agréguelo a la caché10 cache.INTO_CACHE(),11 bytes32(VAL_A),1213 // Segundo valor14 bytes1(0x0F),15 bytes2(0x1234),16 bytes11(0xA10102030405060708090A)17 );Mostrar todoEsta 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 // Probar que, con un búfer excesivamente largo, todo funciona5 function testLongCalldata() public {6 address _cacheAddr = address(cache);7 bool _success;8 bytes memory _callInput;9 bytes memory _callOutput;1011 // Primera llamada, la caché está vacía12 _callInput = bytes.concat(13 FOUR_PARAMS,1415 // Primer valor, agréguelo a la caché16 cache.INTO_CACHE(), bytes32(VAL_A),1718 // Segundo valor, agréguelo a la caché19 cache.INTO_CACHE(), bytes32(VAL_B),2021 // Tercer valor, agréguelo a la caché22 cache.INTO_CACHE(), bytes32(VAL_C),2324 // Cuarto valor, agréguelo a la caché25 cache.INTO_CACHE(), bytes32(VAL_D),2627 // Y otro valor de "buena suerte"28 bytes4(0x31112233)29 );Mostrar todoEsta 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 todoUna aplicación de ejemplo
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 demuestra cómo usar el almacenamiento en caché en una dapp con WORM, que significa «Write Once, Read Many» (Escribir una vez, leer muchas). Si aún no se ha escrito una clave, puede escribirle un valor. Si la clave ya está escrita, obtiene una reversión.
El contrato
Este es el contratoopens 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 {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 } // writeEntryCachedEsta función es similar a fourParam en CacheTest más arriba. Como no seguimos las especificaciones de ABI, es mejor no declarar ningún parámetro en la función.
1 // Facilitar que nos llamen2 // Firma de la función para writeEntryCached(), cortesía de3 // https://www.4byte.directory/signatures/?bytes4_signature=0xe4e4f2d34 bytes4 constant public WRITE_ENTRY_CACHED = 0xe4e4f2d3;El código externo que llama 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 aunque definimos WRITE_ENTRY_CACHED como una variable de estado, para leerla externamente es necesario usar su función getter, worm.WRITE_ENTRY_CACHED().
1 function readEntry(uint key) public view2 returns (uint _value, address _writtenBy, uint _writtenAtBlock)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 contratoopens in a new tab. Una vez más, echemos un vistazo solo 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);Esto (vm.expectRevert)opens in a new tab es cómo especificamos en una prueba de Foundry que la siguiente llamada debe fallar, y la razón informada del fallo. Esto se aplica cuando usamos la sintaxis <contract>.<function name>() en lugar de construir los calldata y llamar al contrato usando la interfaz de bajo nivel (<contract>.call(), etc.).
1 function testReadWriteCached() public {2 uint cacheGoat = worm.cacheWrite(0x60A7);Aquí nos basamos en que cacheWrite devuelve la clave de la 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. Por lo tanto, el valor de retorno de cacheWrite solo es accesible desde el código en la cadena, y el código en la cadena no necesita almacenamiento en caché de parámetros.
1 (_success,) = address(worm).call(_callInput);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);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.
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 todoEsta es la forma en que verificamos que el código emite un evento correctamenteopens 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, desplegué WORM en Optimism Goerliopens in a new tab, la nueva red de prueba de Optimismopens in a new tab. Está en la dirección 0xd34335b1d818cee54e3323d3246bd31d94e6a78aopens 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.git -
Instale los paquetes necesarios:
1cd javascript2yarn -
Copie el archivo de configuración:
1cp .env.example .env -
Edite
.envpara su configuración:Parámetro Valor MNEMONIC El mnemónico 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 URL a Optimism Goerli. El punto de conexión público, https://goerli.optimism.io, tiene una tasa limitada pero es 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 JavaScript de una dapp normal. Así que, de nuevo, solo repasaremos las partes interesantes.
1.2.3.4const main = async () => {5 const func = await worm.WRITE_ENTRY_CACHED()67 // Se necesita una nueva clave cada vez8 const key = await worm.encodeVal(Number(new Date()))En 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// Escribir una entrada4const calldata = func + key.slice(2) + val.slice(2)Ethers espera que los datos de la 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 esas cabeceras.
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 llamar a una función en caché normalmente. En su lugar, necesitamos usar un mecanismo de nivel inferior.
1 .2 .3 .4 // Leer la entrada que se acaba de escribir5 const realKey = '0x' + key.slice(4) // eliminar la bandera FF6 const entryRead = await worm.readEntry(realKey)7 .8 .9 .Mostrar todoPara 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 funcionalidades adicionales:
-
Manejar valores que no sean
uint256. Por ejemplo, cadenas. -
En lugar de una caché global, tal vez se podría tener un mapeo 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 4080 valores se pueden enviar como dos bytes. Los siguientes valores, aproximadamente 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 4080 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 llama a
encodeValpara 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 0x06. 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 0x201122. A un valor diferente, 1018, se le asigna 0x06.
-
Noam Naive envía sus tókenes a 0x06. 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.
Hay formas de resolver este problema y el problema relacionado con las transacciones que están en la mempool 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 costo 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.
Vea aquí más de mi trabajoopens in a new tab.
Última actualización de la página: 19 de septiembre de 2025