Tudo que você pode colocar em cache
Ao usar rollups, o custo de um byte na transação é muito mais caro do que o custo de um slot de armazenamento. Portanto, faz sentido armazenar em cache o máximo de informações on-chain possível.
Neste artigo, você aprenderá a criar e usar um contrato de cache de forma que qualquer valor de parâmetro que provavelmente será usado várias vezes será armazenado em cache e estará disponível para uso (após a primeira vez) com um número muito menor de bytes, e como escrever código off-chain que usa esse cache.
Se quiser pular o artigo e ver apenas o código-fonte, ele está aqui (opens in a new tab). A pilha de desenvolvimento é a Foundry (opens in a new tab).
Projeto geral
Para simplificar, vamos supor que todos os parâmetros da transação são uint256, com 32 bytes de comprimento. Ao receber uma transação, analisaremos cada parâmetro desta forma:
-
Se o primeiro byte for
0xFF, pegue os próximos 32 bytes como um valor de parâmetro e escreva-o no cache. -
Se o primeiro byte for
0xFE, pegue os próximos 32 bytes como um valor de parâmetro, mas não o escreva no cache. -
Para qualquer outro valor, pegue os quatro bits superiores como o número de bytes adicionais e os quatro bits inferiores como os bits mais significativos da chave do cache. Veja aqui alguns exemplos:
Bytes em calldata Chave de cache 0x0F 0x0F 0x10,0x10 0x10 0x12,0xAC 0x02AC 0x2D,0xEA, 0xD6 0x0DEAD6
Manipulação de cache
O cache é implementado em Cache.sol (opens in a new tab). Vamos analisá-lo linha por linha.
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;Essas constantes são usadas para interpretar os casos especiais em que fornecemos todas as informações e queremos ou não que elas sejam gravadas no cache. Escrever no cache requer duas operações SSTORE (opens in a new tab) em slots de armazenamento não utilizados anteriormente, a um custo de 22.100 de gás cada, por isso tornamos isso opcional.
12 mapping(uint => uint) public val2key;Um mapeamento (opens in a new tab) entre os valores e suas chaves. Esta informação é necessária para codificar valores antes de enviar a transação.
1 // A localização n tem o valor da chave n+1, porque precisamos preservar2 // o zero como "não está no cache".3 uint[] public key2val;Podemos usar um array para o mapeamento de chaves para valores, pois atribuímos as chaves e, para simplificar, fazemos isso sequencialmente.
1 function cacheRead(uint _key) public view returns (uint) {2 require(_key <= key2val.length, "Reading uninitialize cache entry");3 return key2val[_key-1];4 } // cacheReadLê um valor do cache.
1 // Escreve um valor no cache se ele ainda não estiver lá2 // Público apenas para permitir que o teste funcione3 function cacheWrite(uint _value) public returns (uint) {4 // Se o valor já estiver no cache, retorne a chave atual5 if (val2key[_value] != 0) {6 return val2key[_value];7 }Não faz sentido colocar o mesmo valor no cache mais de uma vez. Se o valor já estiver lá, basta retornar a chave existente.
1 // Como 0xFE é um caso especial, a maior chave que o cache pode2 // conter é 0x0D seguido por 15 0xFF's. Se o tamanho do cache já for tão3 // grande, falhe.4 // 1 2 3 4 5 6 7 8 9 A B C D E F5 require(key2val.length+1 < 0x0DFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF,6 "cache overflow");Acho que nunca teremos um cache tão grande (aproximadamente 1,8*1037 entradas, o que exigiria cerca de 1027 TB para armazenar). No entanto, tenho idade suficiente para me lembrar de "640kB sempre seriam suficientes" (opens in a new tab). Este teste é muito barato.
1 // Escreve o valor usando a próxima chave2 val2key[_value] = key2val.length+1;Adicione a pesquisa reversa (do valor para a chave).
1 key2val.push(_value);Adicione a pesquisa direta (da chave para o valor). Como atribuímos valores sequencialmente, podemos simplesmente adicioná-lo após o último valor do array.
1 return key2val.length;2 } // cacheWriteRetorna o novo comprimento de key2val, que é a célula onde o novo valor está armazenado.
1 function _calldataVal(uint startByte, uint length)2 private pure returns (uint)Esta função lê um valor do calldata de comprimento arbitrário (até 32 bytes, o tamanho da palavra).
1 {2 uint _retVal;34 require(length < 0x21,5 "O limite de comprimento de _calldataVal é de 32 bytes");6 require(length + startByte <= msg.data.length,7 "_calldataVal tentando ler além do tamanho do calldata");Esta função é interna, então se o resto do código for escrito corretamente, esses testes não são necessários. No entanto, eles não custam muito, então podemos mantê-los.
1 assembly {2 _retVal := calldataload(startByte)3 }Este código está em Yul (opens in a new tab). Ele lê um valor de 32 bytes do calldata. Isso funciona mesmo que o calldata pare antes de startByte+32, porque o espaço não inicializado na EVM é considerado como zero.
1 _retVal = _retVal >> (256-length*8);Não queremos necessariamente um valor de 32 bytes. Isso elimina os bytes em excesso.
1 return _retVal;2 } // _calldataVal345 // Lê um único parâmetro do calldata, começando em _fromByte6 function _readParam(uint _fromByte) internal7 returns (uint _nextByte, uint _parameterValue)8 {Lê um único parâmetro do calldata. Observe que precisamos retornar não apenas o valor que lemos, mas também a localização do próximo byte, porque os parâmetros podem variar de 1 a 33 bytes de comprimento.
1 // O primeiro byte nos diz como interpretar o resto2 uint8 _firstByte;34 _firstByte = uint8(_calldataVal(_fromByte, 1));O Solidity tenta reduzir o número de bugs, proibindo conversões de tipo implícitas (opens in a new tab) potencialmente perigosas. Um rebaixamento, por exemplo, de 256 bits para 8 bits, precisa ser explícito.
12 // Lê o valor, mas não o escreve no cache3 if (_firstByte == uint8(DONT_CACHE))4 return(_fromByte+33, _calldataVal(_fromByte+1, 32));56 // Lê o valor e o escreve no cache7 if (_firstByte == uint8(INTO_CACHE)) {8 uint _param = _calldataVal(_fromByte+1, 32);9 cacheWrite(_param);10 return(_fromByte+33, _param);11 }1213 // Se chegamos aqui, significa que precisamos ler do cache1415 // Número de bytes extras para ler16 uint8 _extraBytes = _firstByte / 16;Exibir tudoPegue o nibble (opens in a new tab) inferior e combine-o com os outros bytes para ler o valor do cache.
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) {Exibir tudoPoderíamos obter o número de parâmetros que temos do próprio calldata, mas as funções que nos chamam sabem quantos parâmetros esperam. É mais fácil que elas nos contem.
1 // Os parâmetros que lemos2 uint[] memory params = new uint[](_paramNum);34 // Os parâmetros começam no byte 4, antes disso é a assinatura da função5 uint _atByte = 4;67 for(uint i=0; i<_paramNum; i++) {8 (_atByte, params[i]) = _readParam(_atByte);9 }Exibir tudoLeia os parâmetros até ter o número que você precisa. Se passarmos do final do calldata, _readParams reverterá a chamada.
12 return(params);3 } // readParams45 // Para testar _readParams, teste a leitura de quatro 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 } // fourParamExibir tudoUma grande vantagem da Foundry é que ela permite que os testes sejam escritos em Solidity (veja Testando o cache abaixo). Isso torna os testes de unidade muito mais fáceis. Esta é uma função que lê quatro parâmetros e os retorna para que o teste possa verificar se eles estavam corretos.
1 // Obtém um valor, retorna os bytes que o codificarão (usando o cache, se possível)2 function encodeVal(uint _val) public view returns(bytes memory) {encodeVal é uma função que o código off-chain chama para ajudar a criar calldata que usa o cache. Ela recebe um único valor e retorna os bytes que o codificam. Esta função é uma view, portanto não requer uma transação e, quando chamada externamente, não custa gás.
1 uint _key = val2key[_val];23 // O valor ainda não está no cache, adicione-o4 if (_key == 0)5 return bytes.concat(INTO_CACHE, bytes32(_val));Na EVM, todo o armazenamento não inicializado é considerado como zero. Então, se procurarmos a chave de um valor que não está lá, obtemos um zero. Nesse caso, os bytes que o codificam são INTO_CACHE (para que seja armazenado em cache da próxima vez), seguido do valor real.
1 // Se a chave for <0x10, retorne-a como um único byte2 if (_key < 0x10)3 return bytes.concat(bytes1(uint8(_key)));Bytes únicos são os mais fáceis. Nós apenas usamos bytes.concat (opens in a new tab) para transformar um tipo bytes<n> em um array de bytes que pode ter qualquer comprimento. Apesar do nome, funciona bem quando fornecido com apenas um argumento.
1 // Valor de dois bytes, codificado como 0x1vvv2 if (_key < 0x1000)3 return bytes.concat(bytes2(uint16(_key) | 0x1000));Quando temos uma chave menor que 163, podemos expressá-la em dois bytes. Primeiro, convertemos _key, que é um valor de 256 bits, para um valor de 16 bits e usamos o OU lógico para adicionar o número de bytes extras ao primeiro byte. Então, o transformamos em um valor bytes2, que pode ser convertido em bytes.
1 // Provavelmente existe uma maneira inteligente de fazer as seguintes linhas como um loop,2 // mas é uma função de visualização, então estou otimizando para o tempo do programador e3 // simplicidade.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)));Exibir tudoOs outros valores (3 bytes, 4 bytes, etc.) são tratados da mesma forma, apenas com tamanhos de campo diferentes.
1 // Se chegarmos aqui, algo está errado.2 revert("Erro em encodeVal, não deveria acontecer");Se chegarmos aqui, significa que obtivemos uma chave que não é menor que 16*25615. Mas cacheWrite limita as chaves para que não possamos chegar a 14*25616 (o que teria um primeiro byte de 0xFE, então se pareceria com DONT_CACHE). Mas não custa muito adicionar um teste caso um futuro programador introduza um bug.
1 } // encodeVal23} // CacheTestando o cache
Uma das vantagens da Foundry é que ela permite que você escreva testes em Solidity (opens in a new tab), o que facilita a escrita de testes de unidade. Os testes para a classe Cache estão aqui (opens in a new tab). Como o código de teste é repetitivo, como os testes tendem a ser, este artigo explica apenas as partes interessantes.
1// SPDX-License-Identifier: UNLICENSED2pragma solidity ^0.8.13;34import "forge-std/Test.sol";567// É necessário executar `forge test -vv` para o console.8import "forge-std/console.sol";Isso é apenas um código padrão necessário para usar o pacote de teste e o console.log.
1import "src/Cache.sol";Precisamos conhecer o contrato que estamos testando.
1contract CacheTest is Test {2 Cache cache;34 function setUp() public {5 cache = new Cache();6 }A função setUp é chamada antes de cada teste. Neste caso, apenas criamos um novo cache, para que nossos testes não afetem uns aos outros.
1 function testCaching() public {Testes são funções cujos nomes começam com test. Esta função verifica a funcionalidade básica do cache, escrevendo valores e lendo-os novamente.
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);É assim que você faz o teste real, usando as funções assert... (opens in a new tab). Neste caso, verificamos que o valor que escrevemos é o que lemos. Podemos descartar o resultado de cache.cacheWrite porque sabemos que as chaves de cache são atribuídas linearmente.
1 }2 } // testCaching345 // Armazena o mesmo valor em cache várias vezes, garante que a chave permaneça6 // a mesma7 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 }Exibir tudoPrimeiro, escrevemos cada valor duas vezes no cache e garantimos que as chaves sejam as mesmas (o que significa que a segunda escrita não aconteceu de fato).
1 for(uint i=1; i<100; i+=3) {2 uint _key = cache.cacheWrite(i);3 assertEq(_key, i);4 }5 } // testRepeatCachingEm teoria, poderia haver um bug que não afeta as escritas consecutivas no cache. Então, aqui fazemos algumas escritas que não são consecutivas e vemos que os valores ainda não são reescritos.
1 // Lê um uint de um buffer de memória (para garantir que recebamos de volta os parâmetros2 // que enviamos)3 function toUint256(bytes memory _bytes, uint256 _start) internal pure4 returns (uint256)Lê uma palavra de 256 bits de um buffer bytes memory. Esta função utilitária nos permite verificar se recebemos os resultados corretos quando executamos uma chamada de função que usa o cache.
1 {2 require(_bytes.length >= _start + 32, "toUint256_fora_dos_limites");3 uint256 tempUint;45 assembly {6 tempUint := mload(add(add(_bytes, 0x20), _start))7 }O Yul não suporta estruturas de dados além de uint256, então, quando você se refere a uma estrutura de dados mais sofisticada, como o buffer de memória _bytes, você obtém o endereço dessa estrutura. O Solidity armazena valores bytes memory como uma palavra de 32 bytes que contém o comprimento, seguida pelos bytes reais, então para obter o byte de número _start precisamos calcular _bytes+32+_start.
12 return tempUint;3 } // toUint25645 // Assinatura da função para fourParams(), cortesia de6 // https://www.4byte.directory/signatures/?bytes4_signature=0x3edc1e6d7 bytes4 constant FOUR_PARAMS = 0x3edc1e6d;89 // Apenas alguns valores constantes para ver se estamos recebendo os valores corretos de volta10 uint256 constant VAL_A = 0xDEAD60A7;11 uint256 constant VAL_B = 0xBEEF;12 uint256 constant VAL_C = 0x600D;13 uint256 constant VAL_D = 0x600D60A7;Exibir tudoAlgumas constantes que precisamos para testar.
1 function testReadParam() public {Chame fourParams(), uma função que usa readParams, para testar se conseguimos ler os parâmetros corretamente.
1 address _cacheAddr = address(cache);2 bool _success;3 bytes memory _callInput;4 bytes memory _callOutput;Não podemos usar o mecanismo ABI normal para chamar uma função usando o cache, então precisamos usar o mecanismo de baixo nível <address>.call() (opens in a new tab). Esse mecanismo usa um bytes memory como entrada e o retorna (assim como um valor booleano) como saída.
1 // First call, the cache is empty2 _callInput = bytes.concat(3 FOUR_PARAMS,É útil que o mesmo contrato suporte tanto funções em cache (para chamadas diretamente de transações) quanto funções não armazenadas em cache (para chamadas de outros contratos inteligentes). Para fazer isso, precisamos continuar a confiar no mecanismo do Solidity para chamar a função correta, em vez de colocar tudo em uma função fallback (opens in a new tab). Fazer isso torna a componentização muito mais fácil. Um único byte seria suficiente para identificar a função na maioria dos casos, então estamos desperdiçando três bytes (16*3=48 de gás). No entanto, no momento em que escrevo, esses 48 de gás custam 0,07 centavos de dólar, o que é um custo razoável para um código mais simples e menos propenso a bugs.
1 // Primeiro valor, adicione-o ao cache2 cache.INTO_CACHE(),3 bytes32(VAL_A),O primeiro valor: um sinalizador dizendo que é um valor completo que precisa ser escrito no cache, seguido pelos 32 bytes do valor. Os outros três valores são semelhantes, exceto que VAL_B não é escrito no cache e VAL_C é tanto o terceiro quanto o quarto parâmetro.
1 .2 .3 .4 );5 (_success, _callOutput) = _cacheAddr.call(_callInput);É aqui que realmente chamamos o contrato Cache.
1 assertEq(_success, true);Esperamos que a chamada seja bem-sucedida.
1 assertEq(cache.cacheRead(1), VAL_A);2 assertEq(cache.cacheRead(2), VAL_C);Começamos com um cache vazio e depois adicionamos VAL_A seguido de VAL_C. Esperaríamos que o primeiro tivesse a chave 1 e o segundo a chave 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);A saída são os quatro parâmetros. Aqui verificamos que está correto.
1 // Segunda chamada, podemos usar o cache2 _callInput = bytes.concat(3 FOUR_PARAMS,45 // Primeiro valor no Cache6 bytes1(0x01),Chaves de cache abaixo de 16 têm apenas um byte.
1 // Segundo valor, não o adicione ao cache2 cache.DONT_CACHE(),3 bytes32(VAL_B),45 // Terceiro e quarto valores, mesmo valor6 bytes1(0x02),7 bytes1(0x02)8 );9 .10 .11 .12 } // testReadParamExibir tudoOs testes após a chamada são idênticos aos da primeira chamada.
1 function testEncodeVal() public {Esta função é semelhante a testReadParam, exceto que, em vez de escrever os parâmetros explicitamente, 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 } // testEncodeValExibir tudoO único teste adicional em testEncodeVal() é verificar se o comprimento de _callInput está correto. Para a primeira chamada, é 4+33*4. Para a segunda, onde cada valor já está no cache, é 4+1*4.
1 // Testa encodeVal quando a chave tem mais de um byte2 // Máximo de três bytes porque preencher o cache até quatro bytes leva3 // muito tempo.4 function testEncodeValBig() public {5 // Coloca uma série de valores no cache.6 // Para simplificar, use a chave n para o valor n.7 for(uint i=1; i<0x1FFF; i++) {8 cache.cacheWrite(i);9 }Exibir tudoA função testEncodeVal acima apenas escreve quatro valores no cache, então a parte da função que lida com valores de vários bytes (opens in a new tab) não é verificada. Mas esse código é complicado e propenso a erros.
A primeira parte desta função é um loop que escreve todos os valores de 1 a 0x1FFF no cache em ordem, para que possamos codificar esses valores e saber para onde eles estão indo.
1 .2 .3 .45 _callInput = bytes.concat(6 FOUR_PARAMS,7 cache.encodeVal(0x000F), // Um byte 0x0F8 cache.encodeVal(0x0010), // Dois bytes 0x10109 cache.encodeVal(0x0100), // Dois bytes 0x110010 cache.encodeVal(0x1000) // Três bytes 0x20100011 );Exibir tudoTesta valores de um, dois e três bytes. Não testamos além disso porque levaria muito tempo para escrever entradas de pilha suficientes (pelo menos 0x10000000, aproximadamente um quarto de bilhão).
1 .2 .3 .4 .5 } // testEncodeValBig678 // Testa o que acontece com um buffer excessivamente pequeno para obter uma reversão9 function testShortCalldata() public {Exibir tudoTesta o que acontece no caso anormal em que não há parâmetros suficientes.
1 .2 .3 .4 (_success, _callOutput) = _cacheAddr.call(_callInput);5 assertEq(_success, false);6 } // testShortCalldataComo ele reverte, o resultado que devemos obter é false.
1 // Chama com chaves de cache que não existem2 function testNoCacheKey() public {3 .4 .5 .6 _callInput = bytes.concat(7 FOUR_PARAMS,89 // Primeiro valor, adicione-o ao cache10 cache.INTO_CACHE(),11 bytes32(VAL_A),1213 // Segundo valor14 bytes1(0x0F),15 bytes2(0x1234),16 bytes11(0xA10102030405060708090A)17 );Exibir tudoEsta função obtém quatro parâmetros perfeitamente legítimos, exceto que o cache está vazio, então não há valores para ler.
1 .2 .3 .4 // Testa o que acontece com um buffer excessivamente longo para ver se tudo funciona bem5 function testLongCalldata() public {6 address _cacheAddr = address(cache);7 bool _success;8 bytes memory _callInput;9 bytes memory _callOutput;1011 // Primeira chamada, o cache está vazio12 _callInput = bytes.concat(13 FOUR_PARAMS,1415 // Primeiro valor, adicione-o ao cache16 cache.INTO_CACHE(), bytes32(VAL_A),1718 // Segundo valor, adicione-o ao cache19 cache.INTO_CACHE(), bytes32(VAL_B),2021 // Terceiro valor, adicione-o ao cache22 cache.INTO_CACHE(), bytes32(VAL_C),2324 // Quarto valor, adicione-o ao cache25 cache.INTO_CACHE(), bytes32(VAL_D),2627 // E outro valor para "dar sorte"28 bytes4(0x31112233)29 );Exibir tudoEsta função envia cinco valores. Sabemos que o quinto valor é ignorado porque não é uma entrada de cache válida, o que teria causado uma reversão se não tivesse sido incluído.
1 (_success, _callOutput) = _cacheAddr.call(_callInput);2 assertEq(_success, true);3 .4 .5 .6 } // testLongCalldata78} // CacheTest9Exibir tudoUm aplicativo de exemplo
Escrever testes em Solidity é muito bom, mas no final das contas um dapp precisa ser capaz de processar solicitações de fora da cadeia para ser útil. Este artigo demonstra como usar o cache em um dapp com WORM, que significa "Write Once, Read Many" (Escreva uma vez, leia muitas). Se uma chave ainda não foi escrita, você pode escrever um valor para ela. Se a chave já estiver escrita, você recebe uma reversão.
O contrato
Este é o contrato (opens in a new tab). Ele repete principalmente o que já fizemos com Cache e CacheTest, então abordaremos apenas as partes interessantes.
1import "./Cache.sol";23contract WORM is Cache {A maneira mais fácil de usar o Cache é herdá-lo em nosso próprio contrato.
1 function writeEntryCached() external {2 uint[] memory params = _readParams(2);3 writeEntry(params[0], params[1]);4 } // writeEntryCachedEsta função é semelhante a fourParam em CacheTest acima. Como não seguimos as especificações da ABI, é melhor não declarar nenhum parâmetro na função.
1 // Facilita a chamada2 // Assinatura da função para writeEntryCached(), cortesia de3 // https://www.4byte.directory/signatures/?bytes4_signature=0xe4e4f2d34 bytes4 constant public WRITE_ENTRY_CACHED = 0xe4e4f2d3;O código externo que chama writeEntryCached precisará construir manualmente o calldata, em vez de usar worm.writeEntryCached, porque não seguimos as especificações da ABI. Ter esse valor constante apenas facilita a escrita.
Observe que, embora definamos WRITE_ENTRY_CACHED como uma variável de estado, para lê-la externamente é necessário usar sua função getter, worm.WRITE_ENTRY_CACHED().
1 function readEntry(uint key) public view2 returns (uint _value, address _writtenBy, uint _writtenAtBlock)A função de leitura é uma view, portanto não requer uma transação e não custa gás. Como resultado, não há benefício em usar o cache para o parâmetro. Com funções de visualização, é melhor usar o mecanismo padrão que é mais simples.
O código de teste
Este é o código de teste para o contrato (opens in a new tab). Novamente, vamos olhar apenas para o que é interessante.
1 function testWReadWrite() public {2 worm.writeEntry(0xDEAD, 0x60A7);34 vm.expectRevert(bytes("entrada já escrita"));5 worm.writeEntry(0xDEAD, 0xBEEF);Isso (vm.expectRevert) (opens in a new tab) é como especificamos em um teste da Foundry que a próxima chamada deve falhar e o motivo relatado para a falha. Isso se aplica quando usamos a sintaxe <contract>.<function name>() em vez de construir o calldata e chamar o contrato usando a interface de baixo nível (<contract>.call(), etc.).
1 function testReadWriteCached() public {2 uint cacheGoat = worm.cacheWrite(0x60A7);Aqui usamos o fato de que cacheWrite retorna a chave do cache. Isso não é algo que esperaríamos usar em produção, porque cacheWrite altera o estado e, portanto, só pode ser chamado durante uma transação. As transações não têm valores de retorno; se tiverem resultados, esses resultados devem ser emitidos como eventos. Portanto, o valor de retorno cacheWrite só é acessível a partir do código na cadeia (on-chain), e o código na cadeia não precisa de cache de parâmetros.
1 (_success,) = address(worm).call(_callInput);É assim que dizemos ao Solidity que, embora <contract address>.call() tenha dois valores de retorno, só nos importamos com o primeiro.
1 (_success,) = address(worm).call(_callInput);2 assertEq(_success, false);Como usamos a função de baixo nível <address>.call(), não podemos usar vm.expectRevert() e temos que observar o valor de sucesso booleano que obtemos da chamada.
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);Exibir tudoÉ assim que verificamos se o código emite um evento corretamente (opens in a new tab) na Foundry.
O cliente
Uma coisa que você não obtém com os testes do Solidity é o código JavaScript que você pode cortar e colar em seu próprio aplicativo. Para escrever esse código, implantei o WORM na Optimism Goerli (opens in a new tab), a nova rede de teste (opens in a new tab) da Optimism (opens in a new tab). Ele está no endereço 0xd34335b1d818cee54e3323d3246bd31d94e6a78a (opens in a new tab).
Você pode ver o código JavaScript do cliente aqui (opens in a new tab). Para usá-lo:
-
Clone o repositório git:
1git clone https://github.com/qbzzt/20220915-all-you-can-cache.git -
Instale os pacotes necessários:
1cd javascript2yarn -
Copie o arquivo de configuração:
1cp .env.example .env -
Edite o
.envpara sua configuração:Parâmetro Valor MNEMÔNICO O mnemônico de uma conta que tem ETH suficiente para pagar por uma transação. Você pode obter ETH grátis para a rede Optimism Goerli aqui (opens in a new tab). OPTIMISM_GOERLI_URL URL para a Optimism Goerli. O endpoint público, https://goerli.optimism.io, tem taxa limitada, mas é suficiente para o que precisamos aqui -
Execute o
index.js.1node index.jsEste aplicativo de exemplo primeiro escreve uma entrada no WORM, exibindo o calldata e um link para a transação no Etherscan. Em seguida, ele lê essa entrada e exibe a chave que usa e os valores na entrada (valor, número do bloco e autor).
A maior parte do cliente é JavaScript de Dapp normal. Então, novamente, vamos abordar apenas as partes interessantes.
1.2.3.4const main = async () => {5 const func = await worm.WRITE_ENTRY_CACHED()67 // Precisa de uma nova chave a cada vez8 const key = await worm.encodeVal(Number(new Date()))Um determinado slot só pode ser escrito uma vez, então usamos o carimbo de data/hora para garantir que não reutilizamos slots.
1const val = await worm.encodeVal("0x600D")23// Escreve uma entrada4const calldata = func + key.slice(2) + val.slice(2)O Ethers espera que os dados da chamada sejam uma string hexadecimal, 0x seguido por um número par de dígitos hexadecimais. Como tanto key quanto val começam com 0x, precisamos remover esses cabeçalhos.
1const tx = await worm.populateTransaction.writeEntryCached()2tx.data = calldata34sentTx = await wallet.sendTransaction(tx)Assim como no código de teste do Solidity, não podemos chamar uma função em cache normalmente. Em vez disso, precisamos usar um mecanismo de nível inferior.
1 .2 .3 .4 // Lê a entrada que acabou de ser escrita5 const realKey = '0x' + key.slice(4) // remove o sinalizador FF6 const entryRead = await worm.readEntry(realKey)7 .8 .9 .Exibir tudoPara ler as entradas, podemos usar o mecanismo normal. Não há necessidade de usar cache de parâmetros com funções view.
Conclusão
O código neste artigo é uma prova de conceito, o objetivo é tornar a ideia fácil de entender. Para um sistema pronto para produção, você pode querer implementar algumas funcionalidades adicionais:
-
Lidar com valores que não são
uint256. Por exemplo, strings. -
Em vez de um cache global, talvez tenha um mapeamento entre usuários e caches. Diferentes usuários usam valores diferentes.
-
Os valores usados para endereços são distintos daqueles usados para outros fins. Pode fazer sentido ter um cache separado apenas para endereços.
-
Atualmente, as chaves do cache estão em um algoritmo "primeiro a chegar, chave menor". Os primeiros dezesseis valores podem ser enviados como um único byte. Os próximos 4080 valores podem ser enviados como dois bytes. Os próximos aproximadamente um milhão de valores são três bytes, etc. Um sistema de produção deve manter contadores de uso nas entradas de cache e reorganizá-los para que os dezesseis valores mais comuns sejam de um byte, os próximos 4080 valores mais comuns de dois bytes, etc.
No entanto, essa é uma operação potencialmente perigosa. Imagine a seguinte sequência de eventos:
-
Noam Naive chama
encodeValpara codificar o endereço para o qual ele quer enviar tokens. Esse endereço é um dos primeiros usados no aplicativo, então o valor codificado é 0x06. Esta é uma funçãoview, não uma transação, então é entre Noam e o nó que ele usa, e mais ninguém sabe sobre isso -
Owen Owner executa a operação de reordenação do cache. Pouquíssimas pessoas realmente usam esse endereço, então ele agora é codificado como 0x201122. Um valor diferente, 1018, é atribuído a 0x06.
-
Noam Naive envia seus tokens para 0x06. Eles vão para o endereço
0x0000000000000000000000000de0b6b3a7640000, e como ninguém conhece a chave privada para esse endereço, eles ficam presos lá. Noam não está feliz.
Existem maneiras de resolver esse problema e o problema relacionado às transações que estão na mempool durante a reordenação do cache, mas você deve estar ciente disso.
-
Demonstrei o cache aqui com a Optimism, porque sou um funcionário da Optimism e este é o rollup que conheço melhor. Mas deve funcionar com qualquer rollup que cobre um custo mínimo para processamento interno, de modo que, em comparação, escrever os dados da transação na L1 seja a maior despesa.
Veja aqui mais do meu trabalho (opens in a new tab).
Última atualização da página: 25 de fevereiro de 2026