Pular para o conteúdo principal

Tudo o que você pode armazenar em cache

camada 2
cache
armazenamento
escalabilidade
Intermediário
Ori Pomerantz
15 de setembro de 2022
23 minutos de leitura

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 possível onchain.

Neste artigo, você aprenderá como criar e usar um contrato de cache de forma que qualquer valor de parâmetro que provavelmente será usado várias vezes seja armazenado em cache e fique disponível para uso (após a primeira vez) com um número muito menor de bytes, e como escrever código offchain que usa esse cache.

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

Design geral

Por uma questão de simplicidade, assumiremos que todos os parâmetros da transação são uint256, com 32 bytes de comprimento. Quando recebermos uma transação, analisaremos cada parâmetro da seguinte forma:

  1. Se o primeiro byte for 0xFF, pegue os próximos 32 bytes como um valor de parâmetro e grave-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 grave no cache.

  3. 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 de cache. Aqui estão alguns exemplos:

    Bytes nos dados de chamadaChave de cache
    0x0F0x0F
    0x10,0x100x10
    0x12,0xAC0x02AC
    0x2D,0xEA, 0xD60x0DEAD6

Manipulação de cache

O cache é implementado em Cache.sol (opens in a new tab). Vamos analisá-lo linha por linha.

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;


contract Cache {

    bytes1 public constant INTO_CACHE = 0xFF;
    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 que sejam gravadas no cache ou não. Gravar no cache exige 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, portanto, tornamos isso opcional.


    mapping(uint => uint) public val2key;

Um mapeamento (opens in a new tab) entre os valores e suas chaves. Essas informações são necessárias para codificar valores antes de enviar a transação.

    // O local n tem o valor para a chave n+1, porque precisamos preservar
    // zero como "não está no cache".
    uint[] public key2val;

Podemos usar uma matriz para o mapeamento de chaves para valores porque nós atribuímos as chaves e, por simplicidade, fazemos isso sequencialmente.

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

Lê um valor do cache.

    // Escreve um valor no cache se ele já não estiver lá
    // Público apenas para permitir que o teste funcione
    function cacheWrite(uint _value) public returns (uint) {
        // Se o valor já estiver no cache, retorna a chave atual
        if (val2key[_value] != 0) {
            return val2key[_value];
        }

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.

        // Como 0xFE é um caso especial, a maior chave que o cache pode
        // conter é 0x0D seguido por 15 0xFF's. Se o comprimento do cache já for desse
        // tamanho, falha.
        //                              1 2 3 4 5 6 7 8 9 A B C D E F
        require(key2val.length+1 < 0x0DFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF,
            "cache overflow");

Não acho que teremos um cache tão grande (aproximadamente 1,8*1037 entradas, o que exigiria cerca de 1027 TB para armazenar). No entanto, sou velho o suficiente para lembrar que "640kB sempre seriam suficientes" (opens in a new tab). Este teste é muito barato.

        // Escreve o valor usando a próxima chave
        val2key[_value] = key2val.length+1;

Adiciona a pesquisa reversa (do valor para a chave).

        key2val.push(_value);

Adiciona a pesquisa direta (da chave para o valor). Como atribuímos valores sequencialmente, podemos simplesmente adicioná-lo após o último valor da matriz.

        return key2val.length;
    }  // cacheWrite

Retorna o novo comprimento de key2val, que é a célula onde o novo valor é armazenado.

    function _calldataVal(uint startByte, uint length)
        private pure returns (uint)

Esta função lê um valor dos dados de chamada de comprimento arbitrário (até 32 bytes, o tamanho da palavra).

    {
        uint _retVal;

        require(length < 0x21,
            "_calldataVal length limit is 32 bytes");
        require(length + startByte <= msg.data.length,
            "_calldataVal trying to read beyond calldatasize");

Esta função é interna, portanto, se o restante do código for escrito corretamente, esses testes não serão necessários. No entanto, eles não custam muito, então é melhor tê-los.

        assembly {
            _retVal := calldataload(startByte)
        }

Este código está em Yul (opens in a new tab). Ele lê um valor de 32 bytes dos dados de chamada. Isso funciona mesmo se os dados de chamada pararem antes de startByte+32 porque o espaço não inicializado na EVM é considerado zero.

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

Não queremos necessariamente um valor de 32 bytes. Isso se livra dos bytes em excesso.

        return _retVal;
    } // _calldataVal


    // Lê um único parâmetro dos dados de chamada, começando em _fromByte
    function _readParam(uint _fromByte) internal
        returns (uint _nextByte, uint _parameterValue)
    {

Lê um único parâmetro dos dados de chamada. 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 byte a 33 bytes de comprimento.

        // O primeiro byte nos diz como interpretar o resto
        uint8 _firstByte;

        _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 downgrade, por exemplo, de 256 bits para 8 bits, precisa ser explícito.

Pega o nibble (opens in a new tab) inferior e o combina com os outros bytes para ler o valor do cache.

Poderíamos obter o número de parâmetros que temos dos próprios dados de chamada, mas as funções que nos chamam sabem quantos parâmetros esperam. É mais fácil deixá-las nos dizer.

Lê os parâmetros até ter o número necessário. Se passarmos do final dos dados de chamada, _readParams reverterá a chamada.

Uma grande vantagem do Foundry é que ele 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 estavam corretos.

    // Obtém um valor, retorna os bytes que o codificarão (usando o cache, se possível)
    function encodeVal(uint _val) public view returns(bytes memory) {

encodeVal é uma função que o código offchain chama para ajudar a criar dados de chamada que usam o cache. Ela recebe um único valor e retorna os bytes que o codificam. Esta função é uma view, portanto, não exige uma transação e, quando chamada externamente, não custa nenhum gás.

        uint _key = val2key[_val];

        // O valor ainda não está no cache, adiciona-o
        if (_key == 0)
            return bytes.concat(INTO_CACHE, bytes32(_val));

Na EVM, todo armazenamento não inicializado é assumido como zeros. Portanto, se procurarmos a chave de um valor que não está lá, obteremos um zero. Nesse caso, os bytes que o codificam são INTO_CACHE (para que seja armazenado em cache na próxima vez), seguidos pelo valor real.

        // Se a chave for <0x10, retorna-a como um único byte
        if (_key < 0x10)
            return bytes.concat(bytes1(uint8(_key)));

Bytes únicos são os mais fáceis. Apenas usamos bytes.concat (opens in a new tab) para transformar um tipo bytes<n> em uma matriz de bytes que pode ter qualquer comprimento. Apesar do nome, funciona bem quando fornecido com apenas um argumento.

        // Valor de dois bytes, codificado como 0x1vvv
        if (_key < 0x1000)
            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, em um valor de 16 bits e usamos o 'ou' lógico para adicionar o número de bytes extras ao primeiro byte. Em seguida, nós o colocamos em um valor bytes2, que pode ser convertido em bytes.

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

        // Se chegarmos aqui, algo está errado.
        revert("Error in encodeVal, should not happen");

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 (que teria um primeiro byte de 0xFE, então se pareceria com DONT_CACHE). Mas não nos custa muito adicionar um teste caso um futuro programador introduza um bug.

    } // encodeVal

}  // Cache

Testando o cache

Uma das vantagens do Foundry é que ele permite que você escreva testes em Solidity (opens in a new tab), o que torna mais fácil escrever 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.

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;

import "forge-std/Test.sol";


// É necessário executar `forge test -vv` para o console.
import "forge-std/console.sol";

Isso é apenas um código clichê (boilerplate) necessário para usar o pacote de teste e console.log.

import "src/Cache.sol";

Precisamos conhecer o contrato que estamos testando.

contract CacheTest is Test {
    Cache cache;

    function setUp() public {
        cache = new Cache();
    }

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.

    function testCaching() public {

Testes são funções cujos nomes começam com test. Esta função verifica a funcionalidade básica do cache, gravando valores e lendo-os novamente.

        for(uint i=1; i<5000; i++) {
            cache.cacheWrite(i*i);
        }

        for(uint i=1; i<5000; i++) {
            assertEq(cache.cacheRead(i), i*i);

É assim que você faz o teste real, usando funções assert... (opens in a new tab). Neste caso, verificamos se o valor que gravamos é o que lemos. Podemos descartar o resultado de cache.cacheWrite porque sabemos que as chaves de cache são atribuídas linearmente.

Primeiro, gravamos cada valor duas vezes no cache e nos certificamos de que as chaves sejam as mesmas (o que significa que a segunda gravação não aconteceu de fato).

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

Em teoria, poderia haver um bug que não afeta gravações consecutivas no cache. Então, aqui fazemos algumas gravações que não são consecutivas e vemos que os valores ainda não são reescritos.

    // Lê um uint de um buffer de memória (para garantir que recebemos de volta os parâmetros
    // que enviamos)
    function toUint256(bytes memory _bytes, uint256 _start) internal pure
        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.

    {
        require(_bytes.length >= _start + 32, "toUint256_outOfBounds");
        uint256 tempUint;

        assembly {
            tempUint := mload(add(add(_bytes, 0x20), _start))
        }

Yul não suporta estruturas de dados além de uint256, portanto, 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, seguido pelos bytes reais, portanto, para obter o número do byte _start, precisamos calcular _bytes+32+_start.

Algumas constantes que precisamos para testar.

    function testReadParam() public {

Chama fourParams(), uma função que usa readParams, para testar se podemos ler os parâmetros corretamente.

        address _cacheAddr = address(cache);
        bool _success;
        bytes memory _callInput;
        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 recebe um bytes memory como entrada e o retorna (bem como um valor booleano) como saída.

        // Primeira chamada, o cache está vazio
        _callInput = bytes.concat(
            FOUR_PARAMS,

É útil que o mesmo contrato suporte funções em cache (para chamadas diretamente de transações) e funções sem 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 composabilidade 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, enquanto escrevo isso, esses 48 de gás custam 0,07 centavos, o que é um custo razoável para um código mais simples e menos propenso a bugs.

            // Primeiro valor, adiciona-o ao cache
            cache.INTO_CACHE(),
            bytes32(VAL_A),

O primeiro valor: Um sinalizador dizendo que é um valor completo que precisa ser gravado no cache, seguido pelos 32 bytes do valor. Os outros três valores são semelhantes, exceto que VAL_B não é gravado no cache e VAL_C é tanto o terceiro parâmetro quanto o quarto.

             .
             .
             .
        );
        (_success, _callOutput) = _cacheAddr.call(_callInput);

É aqui que realmente chamamos o contrato Cache.

        assertEq(_success, true);

Esperamos que a chamada seja bem-sucedida.

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

Começamos com um cache vazio e depois adicionamos VAL_A seguido por VAL_C. Esperaríamos que o primeiro tivesse a chave 1 e o segundo tivesse 2.

assertEq(toUint256(_callOutput,0), VAL_A);
        assertEq(toUint256(_callOutput,32), VAL_B);
        assertEq(toUint256(_callOutput,64), VAL_C);
        assertEq(toUint256(_callOutput,96), VAL_C);

A saída são os quatro parâmetros. Aqui verificamos se está correto.

        // Segunda chamada, podemos usar o cache
        _callInput = bytes.concat(
            FOUR_PARAMS,

            // Primeiro valor no Cache
            bytes1(0x01),

As chaves de cache abaixo de 16 têm apenas um byte.

Os testes após a chamada são idênticos aos após a primeira chamada.

    function testEncodeVal() public {

Esta função é semelhante a testReadParam, exceto que, em vez de gravar os parâmetros explicitamente, usamos encodeVal().

O ú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.

A função testEncodeVal acima grava apenas quatro valores no cache, portanto, 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 grava 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.

Testa valores de um byte, dois bytes e três bytes. Não testamos além disso porque levaria muito tempo para gravar entradas de pilha suficientes (pelo menos 0x10000000, aproximadamente um quarto de bilhão).

Testa o que acontece no caso anormal em que não há parâmetros suficientes.

        .
        .
        .
        (_success, _callOutput) = _cacheAddr.call(_callInput);
        assertEq(_success, false);
    }   // testShortCalldata

Como ele reverte, o resultado que devemos obter é false.

Esta função obtém quatro parâmetros perfeitamente legítimos, exceto que o cache está vazio, então não há valores lá para ler.

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

Um 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" (Grave Uma Vez, Leia Várias). Se uma chave ainda não estiver gravada, você pode gravar um valor nela. Se a chave já estiver gravada, você obterá 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 cobrimos apenas as partes que são interessantes.

import "./Cache.sol";

contract WORM is Cache {

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

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

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

    // Torna mais fácil nos chamar
    // Assinatura de função para writeEntryCached(), cortesia de
    // https://www.4byte.directory/signatures/?bytes4_signature=0xe4e4f2d3
    bytes4 constant public WRITE_ENTRY_CACHED = 0xe4e4f2d3;

O código externo que chama writeEntryCached precisará construir manualmente os dados de chamada, em vez de usar worm.writeEntryCached, porque não seguimos as especificações da ABI. Ter esse valor constante apenas torna mais fácil escrevê-lo.

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

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

A função de leitura é uma view, portanto, não exige 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.

    function testWReadWrite() public {
        worm.writeEntry(0xDEAD, 0x60A7);

        vm.expectRevert(bytes("entry already written"));
        worm.writeEntry(0xDEAD, 0xBEEF);

É assim (vm.expectRevert) (opens in a new tab) que especificamos em um teste do Foundry que a próxima chamada deve falhar e o motivo relatado para uma falha. Isso se aplica quando usamos a sintaxe <contract>.<function name>() em vez de construir os dados de chamada e chamar o contrato usando a interface de baixo nível (<contract>.call(), etc.).

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

Aqui usamos o fato de que cacheWrite retorna a chave de 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. Transações não têm valores de retorno; se tiverem resultados, esses resultados devem ser emitidos como eventos. Portanto, o valor de retorno de cacheWrite só é acessível a partir do código onchain, e o código onchain não precisa de cache de parâmetros.

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

É assim que dizemos ao Solidity que, embora <contract address>.call() tenha dois valores de retorno, nos importamos apenas com o primeiro.

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

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

Esta é a maneira como verificamos se 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 os testes em Solidity é o código JavaScript que você pode recortar 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 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 para o cliente aqui (opens in a new tab). Para usá-lo:

  1. Clone o repositório git:

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

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

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

    ParâmetroValor
    MNEMONICO mnemônico para 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_URLURL para a Optimism Goerli. O endpoint público, https://goerli.optimism.io, tem limite de taxa, mas é suficiente para o que precisamos aqui
  5. Execute index.js.

    node index.js
    

    Este aplicativo de exemplo primeiro grava uma entrada no WORM, exibindo os dados de chamada e um link para a transação no Etherscan. Em seguida, ele lê essa entrada novamente e exibe a chave que usa e os valores na entrada (valor, número do bloco e autor).

A maior parte do cliente é JavaScript normal de dapp. Então, novamente, vamos apenas repassar as partes interessantes.

.
.
.
const main = async () => {
    const func = await worm.WRITE_ENTRY_CACHED()

    // Precisa de uma nova chave todas as vezes
    const key = await worm.encodeVal(Number(new Date()))

Um determinado slot só pode ser gravado uma vez, então usamos o carimbo de data/hora para garantir que não reutilizemos os slots.

const val = await worm.encodeVal("0x600D")

// Escreve uma entrada
const calldata = func + key.slice(2) + val.slice(2)

O Ethers espera que os dados de chamada sejam uma string hexadecimal, 0x seguida por um número par de dígitos hexadecimais. Como key e val começam com 0x, precisamos remover esses cabeçalhos.

const tx = await worm.populateTransaction.writeEntryCached()
tx.data = calldata

sentTx = await wallet.sendTransaction(tx)

Assim como no código de teste em Solidity, não podemos chamar uma função em cache normalmente. Em vez disso, precisamos usar um mecanismo de nível inferior.

Para ler 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 ter um mapeamento entre usuários e caches. Usuários diferentes 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 de cache estão em um algoritmo "primeiro a chegar, menor chave". 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 sejam de 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 deseja enviar tokens. Esse endereço é um dos primeiros usados no aplicativo, então o valor codificado é 0x06. Esta é uma função view, não uma transação, então é entre Noam e o nó que ele usa, e ninguém mais sabe sobre isso

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

    3. Noam Naive envia seus tokens para 0x06. Eles vão para o endereço 0x0000000000000000000000000de0b6b3a7640000 e, como ninguém sabe 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 de 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, gravar os dados da transação na L1 seja a maior despesa.

Veja aqui mais do meu trabalho (opens in a new tab).