Pular para o conteúdo principal

Tudo que você puder armazenar em cache

camada 2armazenamento em cachearmazenamento
Intermediário
Ori Pomerantz
15 de setembro de 2022
23 minutos de leitura minute read

Ao usar roll-ups, o custo de um byte na transação é muito mais caro que o custo de um slot de armazenamento. Portanto, faz sentido armazenar em cache o máximo de informações possível na cadeia.

Neste artigo, você aprenderá como criar e usar um contrato de armazenamento em cache de forma que qualquer valor de parâmetro, provável de ser usado diversas vezes, será armazenado em cache e ficará disponível para uso (depois da primeira vez) com um número muito menor de bytes, e como escrever código off-chain para usar esse cache.

Se você quiser pular o artigo e somente ver o código-fonte, consulte-o aqui(opens in a new tab). A pilha de desenvolvimento é Foundry(opens in a new tab).

Design Geral

Para fins de simplicidade, vamos supor que todos os parâmetros de transação são uint256, com 32 bytes de tamanho. Quando recebemos uma transação, fazemos o parse em cada parâmetro deste modo:

  1. Se o primeiro byte for 0xFF, pegue os 32 bytes seguintes como um valor de parâmetro e escreva-o no cache.

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

  3. Para qualquer outro valor, pegue os primeiros quatro bits como o número de bytes adicionais, e os últimos quatro bits como os bits mais significantes da chave do cache. Veja aqui alguns exemplos:

    Bytes em calldataChave da cache
    0x0F0x0F
    0x10,0x100x10
    0x12,0xAC0x02AC
    0x2D,0xEA, 0xD60x0DEAD6

Manipulação do cache

A cache é implementada em Cache.sol(opens in a new tab). Vamos passar por ele linha a linha.

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

Essas constantes são usadas para interpretar os casos especiais nos quais fornecemos todas as informações, independentemente de as querermos escritas no cache ou não. Escrever no cache requer duas operações SSTORE(opens in a new tab) nos slots de armazenamento previamente não utilizados, ao custo de 22.100 gás cada. Portanto, deixamos isso opcional.

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

Um mapeamento(opens in a new tab) entre os valores e suas chaves. Esta informação é necessária para codificar valores antes de você enviar a transação.

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 uma matriz para mapear das chaves aos valores, pois atribuímos as chaves e, para simplificar, fazemos isso de modo sequencial.

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

Ler um valor da cache.

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

Não faz sentido colocar o mesmo valor no cache mais de uma vez. Se o valor já está lá, apenas retorne a chave 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

Não acho que iremos algum dia ter um cache tão grande (cerca de 1,8*1037 entradas, o que exigiria cerca de 1027 TB de armazenamento). No entanto, eu sou velho o suficiente para lembrar que "640kB sempre será o suficiente"(opens in a new tab). Este teste é muito barato.

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

Adicione a busca reversa (do valor para a chave).

1 key2val.push(_value);
Copiar

Adicione a busca para frente (da chave para o valor). Como atribuímos valores de modo sequencial, podemos apenas adicioná-los depois do último valor da matriz.

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

Retorne o novo tamanho de key2val, que é a célula onde o novo valor está armazenado.

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

Essa função lê um valor de calldata de tamanho arbitrário (até 32 bytes, o tamanho da palavra).

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

A função é interna, por isso, se o resto do código for escrito corretamente, esses testes não serão obrigatórios. Porém, como eles não custam muito, podemos tê-los de qualquer forma.

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

Este código está em Yul(opens in a new tab). Ele lê um valor de 32 bytes do calldata. Isso funciona até mesmo se o calldata parar antes startByte+32, pois o espaço não inicializado na EVM é considerado como zero.

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

Não queremos necessariamente um valor de 32 bytes. Isso elimina os bytes em excesso.

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

Leia um único parâmetro do calldata. Observe que precisamos retornar não somente o valor que lemos, mas também a localização do próximo byte, pois os parâmetros podem estar na faixa de comprimento 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

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.

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

Pegue 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);
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) {
Exibir tudo
Copiar

Poderíamos pegar o número de parâmetros que temos do calldata propriamente dito, mas as funções que nos chamam sabem quantos parâmetros elas esperam. É mais fácil que elas nos contem.

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 }
Exibir tudo
Copiar

Leia os parâmetros até que você tenha o número de que precisa. Se ultrapassarmos o fim do calldata, _readParams reverterá a chamada.

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
Exibir tudo
Copiar

Uma grande vantagem do Foundry é que ele permite que os testes sejam escritos no Solidity (veja o teste de cache abaixo). Isto faz testes unitários muito mais fáceis. Essa é uma função que lê quatro parâmetros e retorna-os para que o teste possa verificar que eles estão corretos.

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 é uma função que o código off-chain chama para ajudar a criar o calldata que usa o cache. Ela recebe um único valor e retorna os bytes que o codificam. Essa função é uma view, portanto, ela não requer uma transação e, quando chamada externamente, não custa nenhum gás.

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

Na EVM todo o armazenamento não inicializado é considerado como zero. Então, se buscarmos a chave de um valor que não está lá, obteremos zero. Nesse caso, os bytes que o codificaram são INTO_CACHE (portanto, ele será armazenado em cache da próxima vez), seguido do 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

Bytes únicos são os mais fáceis. Somente usamos bytes.concat(opens in a new tab) para transformar um tipo de bytes<n> em uma matriz de bytes que pode ser de qualquer tamanho. Apesar do nome, isso funciona bem quando fornecemos somente um argumento.

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

Quando temos uma chave que é inferior a 163, podemos expressá-la em dois bytes. Primeiro, convertemos _key, que é um valor de 256 bits, para um valor de 16 bits e usamos um cálculo lógico para adicionar o número de bytes extras ao primeiro byte. Então, convertemos o byte em um valor bytes2, que pode ser convertido para 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)));
Exibir tudo
Copiar

Os outros valores (3 bytes, 4 bytes, etc.) são manipulados da mesma maneira, apenas com diferentes tamanhos de campo.

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

Se chegarmos até aí, significa que temos a chave que não é inferior a 16*25615. Porém, cacheWrite limita as chaves, portanto, não conseguimos nem mesmo chegar a 14*25616 (o que teria o primeiro byte de 0xFE, que se pareceria com DONT_CACHE). Mas ele não nos custa tanto para adicionar um teste caso um futuro programador introduza um bug.

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

Testando o cache

Uma das vantagens do Foundry é que ele deixa você escrever testes em Solidity(opens in a new tab), o que facilita escrever testes de unidade. Os testes para a classe Cache estão aqui(opens in a new tab). Como o código de teste pode ser repetitivo, assim como os testes tendem a ser, este artigo explica apenas as partes interessantes.

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

Isso é apenas um modelo necessário para usar o pacote de teste e console.log.

1import "src/Cache.sol";
Copiar

Precisamos conhecer o contrato que estamos testando.

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

A função setUp é chamada antes de cada teste. Nesse caso, acabamos de criar um novo cache, de modo que nossos testes não sejam afetados um pelo outro.

1 function testCaching() public {
Copiar

Testes são funções cujos nomes começam com test. Essa 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 }
4
5 for(uint i=1; i<5000; i++) {
6 assertEq(cache.cacheRead(i), i*i);
Copiar

Isto é como você faz teste realmente, usando as funções assert...(opens in a new tab). Nesse caso, nós verificamos que o valor que escrevemos é o mesmo que lemos. Podemos descartar o resultado de cache.cacheWrite, pois sabemos que as chaves do cache são atribuídos linearmente.

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 }
Exibir tudo
Copiar

Primeiro, escrevemos cada valor duas vezes para o cache e nos certificamos de que as chaves são as mesmas (ou seja, a segunda escrita não aconteceu realmente).

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

Na teoria poderia haver um bug que não afetasse escritas em cache consecutivas. Então, fazemos aqui algumas escritas que não sejam consecutivas e observamos que os valores ainda não foram reescritos.

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

Leia uma palavra de 256 bits de um buffer de bytes memory. Essa função utilitária nos deixa verificar que recebemos os resultados corretos quando executamos uma chamada de função que usa o cache.

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

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 um 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 tamanho, seguida dos bytes reais, então, para obter o número de bytes _start, precisamos 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;
Exibir tudo
Copiar

Algumas constantes de que precisamos para os testes.

1 function testReadParam() public {
Copiar

Chame fourParams(), uma função que usa readParams, para testar nós podemos ler parâmetros corretamente.

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

Não podemos usar o mecanismo de ABI normal para chamar uma função usando o cache, por isso, precisamos usar o mecanismo de baixo nível <address>.call()(opens in a new tab). Esse mecanismo pega um bytes memory como entrada e retorna aquele (assim como o valor booleano) como saída.

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

É útil para o mesmo contrato suportar ambas funções em cache (para chamadas diretamente de transações) e funções não em cache (para chamadas de outros contratos inteligentes). Para fazer isso nós precisamos continuar a confiar no mecanismo Solidity para chamar a função correta, ao invés de pôr tudo em uma função fallback(opens in a new tab). Fazer isso torna a componibilidade muito mais fácil. Um único byte seria suficiente para identificar a função na maioria dos casos, por isso, estamos desperdiçando três bytes (16*3=48 gás). No entanto, no momento em que escrevo este artigo, 48 gás custam 0,07 centavos de dólar, o que é um custo razoável para um código mais simples e menos sujeito a bugs.

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

O primeiro valor: Um sinalizador dizendo que é um valor que precisa ser escrito na cache, seguido pelos 32 bytes do valor. Os outros três valores são similares, exceto que VAL_B não é escrito no cache e VAL_C é ambos o terceiro e quarto parâmetros.

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

É aqui que realmente chamamos o contrato Cache.

1 assertEq(_success, true);
Copiar

Nós esperamos que a chamada tenha sucesso.

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

Nós começamos com uma cache vazia e então adicionamos VAL_A seguida de VAL_C. Nós esperaríamos a primeira ter a chave 1, e a segunda ter a 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 é composta pelos quatro parâmetros. Aqui, verificamos que está correto.

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

As chaves de cache abaixo de 16 correspondem a apenas um 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
Exibir tudo
Copiar

Os testes depois da chamada são idênticos a aqueles depois da primeira chamada.

1 function testEncodeVal() public {
Copiar

Esta função é similar a testReadParam, exceto que ao invés de escrever os parâmetros explicitamente, nós 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
Exibir tudo
Copiar

O único teste adicional em testEncodeVal() é verificar que o comprimento de _callInput está correto. Para a primeira chamada, ele é 4+33*4. Para a segunda, na qual cada valor já está no cache, ele é 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 }
Exibir tudo
Copiar

A função testEncodeVal acima somente escreve quatro valores na cache, então a parte da função que lida com valores multi-byte(opens in a new tab) não é checada. Porém, esse código é complicado e sujeito a erros.

A primeira parte dessa função é um loop que escreve todos os valores de 1 até 0x1FFF para o cache em ordem, a fim de podermos codificar esses valores e saber para onde eles estão indo.

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

Teste valores de um byte, dois bytes e três bytes. Não testamos além disso, pois levaria tempo demais para escrever entradas de pilha suficientes (pelo menos 0x10000000, cerca de um quarto de bilhão).

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

Teste 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 } // testShortCalldata
Copiar

Como ele é revertido, o resultado deve ser 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 );
Exibir tudo

Esta função pega quatro parâmetros perfeitamente legítimos, exceto que a cache está vazia, então não há valores lá para ler.

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

Esta função envia cinco valores. Sabemos que o quinto valor é ignorado porque não é uma entrada de cache válida, o que causaria uma reversão se não tivesse sido incluída.

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

Uma amostra do aplicativo

Escrever testes em Solidity é tudo muito bem, mas no final do dia, um dapp precisa ser capaz de processar requisições de fora da cadeia para ser útil. Este artigo demonstra como usar o cache em um dapp com WORM, que significa “escrever uma vez, ler várias” (em inglês, "Write Once, Read Many"). Se uma chave ainda não estiver escrita, você pode escrever um valor para ela. Se a chave já estiver escrita, você terá uma reversão.

O contrato

Este é o contrato(opens in a new tab). Ele repete, em grande parte, o que já fizemos com Cache e CacheTest, então abrangeremos somente as partes que são interessantes.

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

A maneira mais fácil de usar Cache é herdá-lo no seu próprio contrato.

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

Essa função é similar a fourParam no CacheTest acima. Como nós não seguimos as especificações da ABI, é melhor não declarar nenhum parâmetro dentro da função.

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

O código externo que chama writeEntryCached precisará construir manualmente o calldata, ao invés de usar worm.writeEntryCached, porque nós não seguimos as especificações da ABI. Tendo o valor desta constante só facilita escrevê-la.

Observe que, apesar de definirmos WRITE_ENTRY_CACHED como uma variável de estado, para lê-la externamente é necessário usar a função getter, worm.WRITE_ENTRY_CACHED().

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

A função de leitura é uma view, então ela não requer uma transação e não custa gas. Como resultado, não há benefício de usar cache para o parâmetro. Com funções view é 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 ver somente o que é interessante.

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

Este (vm.expectRevert)(opens in a new tab) é como especificamos em um teste Foundry que a próxima chamada deve falhar, assim como a razão dessa falha. Isto se aplica quando nós usamos a sintaxe <contract>.<function name>() ao invés de construir o calldata e chamar o contrato usando interface de baixo nível (<contract>.call(), etc.).

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

Aqui nós usamos o fato de cacheWrite retornar a chave da cache. Isto não é algo que nós esperaríamos usar em produção, porque cacheWrite altera o estado, e por isso pode ser chamado apenas durante a transação. Transações não têm valores de retorno. Se elas têm resultados, esses resultados devem ser supostamente emitidos como eventos. Assim, o valor de retorno de cacheWrite é somente acessível do código on-chain, e o código on-chain não precisa armazenar parâmetros em cache.

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

É assim que contamos ao Solidity que, enquanto <contract address>.call() tem dois valores de retorno, só nos importamos com o primeiro.

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

Como usamos a função de baixo nível <address>.call(), não podemos usar vm.expectRevert() e temos de olhar para o valor de êxito booleano que obtivemos da chamada.

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

Essa é a maneira que verificamos que o código emite um evento corretamente(opens in a new tab) no Foundry.

O cliente

Uma coisa que você não obtém com testes no Solidity é código JavaScript, que você pode cortar e colar no seu próprio aplicativo. Para escrever este código, implantei WORM na Optimism Goerli(opens in a new tab), a nova rede de teste da Optimism(opens in a new tab). Ela está no endereço 0xd34335b1d818cee54e3323d3246bd31d94e6a78a(opens in a new tab).

Você pode ver o código JavaScript para o cliente aqui(opens in a new tab). Para usá-lo:

  1. Clone o repositório git:

    1git clone https://github.com/qbzzt/20220915-all-you-can-cache.git
  2. Instale os pacotes necessários:

    1cd javascript
    2yarn
  3. Copie o arquivo de configuração:

    1cp .env.example .env
  4. Edite .env para a sua configuração:

    ParâmetroValor
    MNEMÔNICOO mnemônico para uma conta que tem ETH suficiente para pagar por uma transação. Você consegue ETH grátis para a rede Optimism Goerli aqui(opens in a new tab).
    OPTIMISM_GOERLI_URLURL da Optimism Goerli. O endpoint público, https://goerli.optimism.io, tem taxa limitada mas suficiente para o que precisamos aqui
  5. Rode index.js.

    1node index.js

    Primeiro, esse exemplo de aplicativo escreve uma entrada para WORM, exibindo o calldata e um link para a transação no Etherscan. Em seguida, ele lê novamente essa entrada e exibe a chave que usou e os valores na entrada (valor, bloco, número e autor).

A maioria dos clientes é Javascript Dapp normal. Então, novamente, passaremos apenas pelas partes interessantes.

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

Um dado slot pode ser apenas escrito uma vez, então usamos o carimbo de data/hora para ter certeza de que não vamos reutilizar esses slots.

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

Ethers espera que o dado da chamada seja uma cadeia de caracteres hexadecimal, 0x seguida de um número par de dígitos hexadecimais. Como key e val começam com 0x, precisamos remover esses cabeçalhos.

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

Como no código de teste Solidity, não podemos chamar uma função em cache normalmente. Ao invés disso, nós precisamos usar um mecanismo de nível mais baixo.

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 .
Exibir tudo

Para ler entradas, podemos usar o mecanismo normal. Não há necessidade de armazenar em cache parâmetros com funções view.

Conclusão

O código neste artigo é uma prova de conceito, a finalidade é tornar a ideia fácil de entender. Para um sistema pronto para produção, recomenda-se implementar funcionalidades adicionais:

  • Manipular valores que não são uint256. Por exemplo, cadeias de caracteres.

  • Em vez de um cache global, talvez ter um mapeamento entre usuários e caches. Usuários diferentes usam valores diferentes.

  • Valores usados para endereços são distintos daqueles usados para outras finalidades. Pode fazer sentido ter um cache separado só para endereços.

  • Atualmente, as chaves de cache estão em um algoritmo do tipo “o primeiro que chega tem a chave menor”. Os primeiros dezesseis valores podem ser enviados como um único byte. Os próximos 4.080 valores podem ser enviados como dois bytes. Os próximos milhões de valores são três bytes, etc. Um sistema de produção deveria manter contadores de uso nas entradas de cache e reorganizá-las para que os dezesseis mais comuns valores sejam um byte, os próximos 4080 valores mais comuns sejam dois bytes, etc.

    No entanto, essa é uma operação potencialmente perigosa. Imagine a seguinte sequência de eventos:

    1. Noam Naive chama encodeVal para codificar o endereço para o qual ele quer enviar tokens. Este endereço é um dos primeiros usados na aplicação, então o valor codificado é 0x06. Trata-se de uma função view, e não uma transação, então ela diz respeito unicamente a Noam e ao nó que ele usa, e ninguém mais sabe disso

    2. Owen Owner executa a operação de reordenação de cache. Muito poucas pessoas realmente usam esse endereço, por isso, ele é agora codificado como 0x201122. Para um valor diferente, 1018, é atribuído 0x06.

    3. Noam Naive envia seus tokens para 0x06. Eles vão para o endereço 0x0000000000000000000000000de0b6b3a7640000, e já que ninguém sabe a chave privada para esse endereço, eles ficam presos lá. Noam não está contente.

    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 atento a isso.

Demonstrei o processo de armazenamento em cache aqui com o Optimism, porque sou funcionário da Optimism e esse é o roll-up que conheço melhor. Mas deve funcionar com qualquer rollup que cobre um mínimo custo por processamento interno, de modo que em comparação com escrever os dados da transação na L1 é a maior despesa.

Última edição: @MCreimer(opens in a new tab), 19 de janeiro de 2024

Este tutorial foi útil?