ABIs curtos para otimização de Calldata
Introdução
Neste artigo, você aprenderá sobre optimistic rollups, o custo das transações neles, e como essa estrutura de custos diferente exige que otimizemos para coisas diferentes do que na Rede Principal do Ethereum. Você também aprende como implementar essa otimização.
Divulgação completa
Sou funcionário em tempo integral da Optimism (opens in a new tab), então os exemplos deste artigo serão executados na Optimism. No entanto, a técnica explicada aqui deve funcionar tão bem para outros rollups.
Terminologia
Ao discutir rollups, o termo 'camada 1' (L1) é usado para a Rede Principal, a rede de produção da Ethereum. O termo 'camada 2' (L2) é usado para o rollup ou qualquer outro sistema que dependa da L1 para segurança, mas que faça a maior parte de seu processamento fora da cadeia.
Como podemos reduzir ainda mais o custo das transações L2?
Os Optimistic rollups precisam preservar um registro de todas as transações históricas para que qualquer pessoa possa analisá-las e verificar se o estado atual está correto. A forma mais barata de inserir dados na Rede Principal do Ethereum é gravá-los como calldata. Esta solução foi escolhida tanto pela Optimism (opens in a new tab) quanto pela Arbitrum (opens in a new tab).
Custo das transações L2
O custo das transações L2 é composto por dois componentes:
- Processamento L2, que geralmente é extremamente barato
- Armazenamento L1, que está atrelado aos custos de gás da Rede Principal
No momento em que escrevo isto, na Optimism o custo do gás L2 é de 0,001 Gwei. O custo do gás na L1, por outro lado, é de aproximadamente 40 gwei. Você pode ver os preços atuais aqui (opens in a new tab).
Um byte de calldata custa 4 de gás (se for zero) ou 16 de gás (se for qualquer outro valor). Uma das operações mais caras na EVM é gravar no armazenamento. O custo máximo para gravar uma palavra de 32 bytes no armazenamento em L2 é de 22.100 de gás. Atualmente, isso é 22.1 gwei. Portanto, se conseguirmos economizar um único byte zero de calldata, poderemos gravar cerca de 200 bytes no armazenamento e ainda sair ganhando.
A ABI
A grande maioria das transações acessa um contrato de uma conta de propriedade externa. A maioria dos contratos é escrita em Solidity e interpreta seu campo de dados de acordo com a interface binária de aplicação (ABI) (opens in a new tab).
No entanto, a ABI foi projetada para a L1, onde um byte de calldata custa aproximadamente o mesmo que quatro operações aritméticas, e não para a L2, onde um byte de calldata custa mais de mil operações aritméticas. O calldata é dividido da seguinte forma:
| Seção | Comprimento | Bytes | Bytes desperdiçados | Gás desperdiçado | Bytes necessários | Gás necessário |
|---|---|---|---|---|---|---|
| Seletor de função | 4 | 0-3 | 3 | 48 | 1 | 16 |
| Zeros | 12 | 4-15 | 12 | 48 | 0 | 0 |
| Endereço de destino | 20 | 16-35 | 0 | 0 | 20 | 320 |
| Quantidade | 32 | 36-67 | 17 | 64 | 15 | 240 |
| Total | 68 | 160 | 576 |
Explicação:
- Seletor de função: o contrato tem menos de 256 funções, então podemos distingui-las com um único byte. Esses bytes normalmente não são zero e, portanto, custam dezesseis de gás (opens in a new tab).
- Zeros: esses bytes são sempre zero porque um endereço de vinte bytes não requer uma palavra de trinta e dois bytes para o conter.
Bytes que contêm zero custam quatro de gás (consulte o yellow paper (opens in a new tab), Apêndice G,
p. 27, o valor para
Gtxdatazero). - Quantidade: se assumirmos que neste contrato
decimalsé dezoito (o valor normal) e a quantidade máxima de tokens que transferimos será 1018, obteremos uma quantidade máxima de 1036. 25615 > 1036, então quinze bytes são suficientes.
Um desperdício de 160 de gás na L1 é normalmente insignificante. Uma transação custa pelo menos 21.000 de gás (opens in a new tab), então 0,8% a mais não importa.
Entretanto, na L2, as coisas são diferentes. Quase todo o custo da transação é para gravá-la na L1.
Além do calldata da transação, há 109 bytes de cabeçalho da transação (endereço de destino, assinatura etc.).
O custo total é, portanto, 109*16+576+160=2480, e estamos desperdiçando cerca de 6,5% disso.
Reduzindo custos quando você não controla o destino
Assumindo que você não tenha controle sobre o contrato de destino, você ainda pode usar uma solução semelhante a esta (opens in a new tab). Vamos rever os arquivos relevantes.
Token.sol
Este é o contrato de destino (opens in a new tab).
É um contrato padrão ERC-20, com um recurso adicional.
Esta função faucet permite que qualquer usuário obtenha alguns tokens para usar.
Isso tornaria um contrato de produção ERC-20 inútil, mas facilita as coisas quando um ERC-20 existe apenas para facilitar os testes.
1 /**2 * @dev Fornece ao chamador 1000 tokens para usar3 */4 function faucet() external {5 _mint(msg.sender, 1000);6 } // function faucetCalldataInterpreter.sol
Este é o contrato que as transações devem chamar com calldata mais curto (opens in a new tab). Vamos analisá-lo linha por linha.
1//SPDX-License-Identifier: Unlicense2pragma solidity ^0.8.0;345import { OrisUselessToken } from "./Token.sol";Precisamos da função do token para saber como chamá-la.
1contract CalldataInterpreter {23 OrisUselessToken public immutable token;O endereço do token para o qual somos um proxy.
12 /**3 * @dev Especifique o endereço do token4 * @param tokenAddr_ Endereço do contrato ERC-205 */6 constructor(7 address tokenAddr_8 ) {9 token = OrisUselessToken(tokenAddr_);10 } // constructorExibir tudoO endereço do token é o único parâmetro que precisamos especificar.
1 function calldataVal(uint startByte, uint length)2 private pure returns (uint) {Leia um valor do calldata.
1 uint _retVal;23 require(length < 0x21,4 "calldataVal length limit is 32 bytes");56 require(length + startByte <= msg.data.length,7 "calldataVal trying to read beyond calldatasize");Vamos carregar uma única palavra de 32 bytes (256 bits) para a memória e remover os bytes que não fazem parte do campo que queremos. Este algoritmo não funciona para valores maiores que 32 bytes e, claro, não podemos ler além do final do calldata. Na L1, pode ser necessário pular esses testes para economizar gás, mas na L2 o gás é extremamente barato, o que permite quaisquer verificações de sanidade que possamos imaginar.
1 assembly {2 _retVal := calldataload(startByte)3 }Poderíamos ter copiado os dados da chamada para fallback() (veja abaixo), mas é mais fácil usar Yul (opens in a new tab), a linguagem assembly da EVM.
Aqui usamos o opcode CALLDATALOAD (opens in a new tab) para ler os bytes de startByte a startByte+31 na pilha.
Em geral, a sintaxe de um opcode em Yul é <opcode name>(<first stack value, if any>,<second stack value, if any>...).
12 _retVal = _retVal >> (256-length*8);Apenas os bytes de comprimento mais significativos fazem parte do campo, então nós deslocamos para a direita (opens in a new tab) para nos livrarmos dos outros valores.
Isso tem a vantagem adicional de mover o valor para a direita do campo, então é o valor em si, e não o valor vezes 256algo.
12 return _retVal;3 }456 fallback() external {Quando uma chamada para um contrato Solidity não corresponde a nenhuma das assinaturas de função, ela chama a função fallback() (opens in a new tab) (supondo que haja uma).
No caso do CalldataInterpreter, qualquer chamada chega aqui porque não há outras funções external ou public.
1 uint _func;23 _func = calldataVal(0, 1);Leia o primeiro byte do calldata, que nos diz a função. Há duas razões pelas quais uma função não estaria disponível aqui:
- Funções que são
pureouviewnão alteram o estado e não custam gás (quando chamadas fora da cadeia). Não faz sentido tentar reduzir seu custo de gás. - Funções que dependem de
msg.sender(opens in a new tab). O valor demsg.senderserá o endereço doCalldataInterpreter, não o do chamador.
Infelizmente, olhando para as especificações do ERC-20 (opens in a new tab), isso deixa apenas uma função, transfer.
Isso nos deixa com apenas duas funções: transfer (porque podemos chamar transferFrom) e faucet (porque podemos transferir os tokens de volta para quem nos chamou).
12 // Chame os métodos de alteração de estado do token usando3 // informações do calldata45 // faucet6 if (_func == 1) {Uma chamada para faucet(), que não tem parâmetros.
1 token.faucet();2 token.transfer(msg.sender,3 token.balanceOf(address(this)));4 }Depois que chamamos token.faucet(), nós recebemos tokens. No entanto, como o contrato de proxy, não precisamos de tokens.
A EOA (conta de propriedade externa) ou contrato que nos chamou precisa.
Então, transferimos todos os nossos tokens para quem nos chamou.
1 // transferir (assumir que temos uma permissão para isso)2 if (_func == 2) {A transferência de tokens requer dois parâmetros: o endereço de destino e a quantidade.
1 token.transferFrom(2 msg.sender,Nós apenas permitimos que os chamadores transfiram tokens que eles possuem
1 address(uint160(calldataVal(1, 20))),O endereço de destino começa no byte #1 (o byte #0 é a função). Como um endereço, ele tem 20 bytes de comprimento.
1 calldataVal(21, 2)Para este contrato em particular, assumimos que o número máximo de tokens que alguém gostaria de transferir cabe em dois bytes (menos de 65536).
1 );2 }No geral, uma transferência leva 35 bytes de calldata:
| Seção | Comprimento | Bytes |
|---|---|---|
| Seletor de função | 1 | 0 |
| Endereço de destino | 32 | 1-32 |
| Quantidade | 2 | 33-34 |
1 } // fallback23} // contract CalldataInterpretertest.js
Este teste de unidade JavaScript (opens in a new tab) nos mostra como usar este mecanismo (e como verificar se ele funciona corretamente). Vou supor que você entende chai (opens in a new tab) e ethers (opens in a new tab) e explicar apenas as partes que se aplicam especificamente ao contrato.
1const { expect } = require("chai");23describe("CalldataInterpreter", function () {4 it("Deve nos permitir usar tokens", async function () {5 const Token = await ethers.getContractFactory("OrisUselessToken")6 const token = await Token.deploy()7 await token.deployed()8 console.log("Token addr:", token.address)910 const Cdi = await ethers.getContractFactory("CalldataInterpreter")11 const cdi = await Cdi.deploy(token.address)12 await cdi.deployed()13 console.log("CalldataInterpreter addr:", cdi.address)1415 const signer = await ethers.getSigner()Exibir tudoComeçamos implantando ambos os contratos.
1 // Obter tokens para usar2 const faucetTx = {Não podemos usar as funções de alto nível que normalmente usaríamos (como token.faucet()) para criar transações, porque não seguimos a ABI.
Em vez disso, temos que construir a transação nós mesmos e depois enviá-la.
1 to: cdi.address,2 data: "0x01"Existem dois parâmetros que precisamos fornecer para a transação:
to, o endereço de destino. Este é o contrato interpretador de calldata.data, o calldata a ser enviado. No caso de uma chamada de faucet, os dados são um único byte,0x01.
12 }3 await (await signer.sendTransaction(faucetTx)).wait()Chamamos o método sendTransaction do assinante (opens in a new tab) porque já especificamos o destino (faucetTx.to) e precisamos que a transação seja assinada.
1// Verifique se o faucet fornece os tokens corretamente2expect(await token.balanceOf(signer.address)).to.equal(1000)Aqui verificamos o saldo.
Não há necessidade de economizar gás em funções view, então apenas as executamos normalmente.
1// Dê ao CDI uma permissão (aprovações não podem ser intermediadas por proxy)2const approveTX = await token.approve(cdi.address, 10000)3await approveTX.wait()4expect(await token.allowance(signer.address, cdi.address)).to.equal(10000)Dê ao interpretador de calldata uma permissão para poder fazer transferências.
1// Transferir tokens2const destAddr = "0xf5a6ead936fb47f342bb63e676479bddf26ebe1d"3const transferTx = {4 to: cdi.address,5 data: "0x02" + destAddr.slice(2, 42) + "0100",6}Crie uma transação de transferência. O primeiro byte é "0x02", seguido pelo endereço de destino e, finalmente, a quantidade (0x0100, que é 256 em decimal).
1 await (await signer.sendTransaction(transferTx)).wait()23 // Verifique se temos 256 tokens a menos4 expect (await token.balanceOf(signer.address)).to.equal(1000-256)56 // E que nosso destino os recebeu7 expect (await token.balanceOf(destAddr)).to.equal(256)8 }) // it9}) // describeExibir tudoReduzindo o custo quando você controla o contrato de destino
Se você tiver controle sobre o contrato de destino, poderá criar funções que contornem as verificações do msg.sender porque elas confiam no interpretador de calldata.
Você pode ver um exemplo de como isso funciona aqui, na ramificação control-contract (opens in a new tab).
Se o contrato estivesse respondendo apenas a transações externas, poderíamos nos virar com apenas um contrato. No entanto, isso quebraria a componibilidade. É muito melhor ter um contrato que responda a chamadas normais de ERC-20 e outro contrato que responda a transações com dados de chamada curtos.
Token.sol
Neste exemplo, podemos modificar Token.sol.
Isso nos permite ter várias funções que apenas o proxy pode chamar.
Aqui estão as novas partes:
1 // O único endereço com permissão para especificar o endereço do CalldataInterpreter2 address owner;34 // O endereço do CalldataInterpreter5 address proxy = address(0);O contrato ERC-20 precisa saber a identidade do proxy autorizado. No entanto, não podemos definir essa variável no construtor, porque ainda não sabemos o valor. Este contrato é instanciado primeiro porque o proxy espera o endereço do token em seu construtor.
1 /**2 * @dev Chama o construtor ERC20.3 */4 constructor(5 ) ERC20("Oris useless token-2", "OUT-2") {6 owner = msg.sender;7 }O endereço do criador (chamado owner) é armazenado aqui porque esse é o único endereço com permissão para definir o proxy.
1 /**2 * @dev define o endereço para o proxy (o CalldataInterpreter).3 * Só pode ser chamado uma vez pelo proprietário4 */5 function setProxy(address _proxy) external {6 require(msg.sender == owner, "Só pode ser chamado pelo proprietário");7 require(proxy == address(0), "O proxy já foi definido");89 proxy = _proxy;10 } // function setProxyExibir tudoO proxy tem acesso privilegiado, porque pode contornar as verificações de segurança.
Para garantir que possamos confiar no proxy, permitimos que apenas owner chame esta função, e apenas uma vez.
Uma vez que proxy tem um valor real (diferente de zero), esse valor não pode mudar, então mesmo que o proprietário decida se tornar malicioso, ou o mnemônico para ele seja revelado, ainda estamos seguros.
1 /**2 * @dev Algumas funções só podem ser chamadas pelo proxy.3 */4 modifier onlyProxy {Esta é uma função modifier (opens in a new tab), que modifica a forma como outras funções funcionam.
1 require(msg.sender == proxy);Primeiro, verifique se fomos chamados pelo proxy e por mais ninguém.
Se não, reverta.
1 _;2 }Se sim, execute a função que modificamos.
1 /* Funções que permitem que o proxy realmente atue como proxy para as contas */23 function transferProxy(address from, address to, uint256 amount)4 public virtual onlyProxy() returns (bool)5 {6 _transfer(from, to, amount);7 return true;8 }910 function approveProxy(address from, address spender, uint256 amount)11 public virtual onlyProxy() returns (bool)12 {13 _approve(from, spender, amount);14 return true;15 }1617 function transferFromProxy(18 address spender,19 address from,20 address to,21 uint256 amount22 ) public virtual onlyProxy() returns (bool)23 {24 _spendAllowance(from, spender, amount);25 _transfer(from, to, amount);26 return true;27 }Exibir tudoEstas são três operações que normalmente exigem que a mensagem venha diretamente da entidade que transfere tokens ou aprova uma permissão. Aqui temos uma versão proxy dessas operações que:
- É modificada por
onlyProxy()para que ninguém mais possa controlá-las. - Recebe o endereço que normalmente seria
msg.sendercomo um parâmetro extra.
CalldataInterpreter.sol
O interpretador de calldata é quase idêntico ao anterior, exceto que as funções com proxy recebem um parâmetro msg.sender e não há necessidade de uma permissão para transfer.
1 // transferir (não é necessária permissão)2 if (_func == 2) {3 token.transferProxy(4 msg.sender,5 address(uint160(calldataVal(1, 20))),6 calldataVal(21, 2)7 );8 }910 // aprovar11 if (_func == 3) {12 token.approveProxy(13 msg.sender,14 address(uint160(calldataVal(1, 20))),15 calldataVal(21, 2)16 );17 }1819 // transferirDe20 if (_func == 4) {21 token.transferFromProxy(22 msg.sender,23 address(uint160(calldataVal( 1, 20))),24 address(uint160(calldataVal(21, 20))),25 calldataVal(41, 2)26 );27 }Exibir tudoTest.js
Existem algumas alterações entre o código de teste anterior e este.
1const Cdi = await ethers.getContractFactory("CalldataInterpreter")2const cdi = await Cdi.deploy(token.address)3await cdi.deployed()4await token.setProxy(cdi.address)Precisamos dizer ao contrato ERC-20 em qual proxy confiar
1console.log("CalldataInterpreter addr:", cdi.address)23// Precisa de dois assinantes para verificar as permissões4const signers = await ethers.getSigners()5const signer = signers[0]6const poorSigner = signers[1]Para verificar approve() e transferFrom(), precisamos de um segundo assinante.
Nós o chamamos de poorSigner porque ele não recebe nenhum de nossos tokens (ele precisa ter ETH, é claro).
1// Transferir tokens2const destAddr = "0xf5a6ead936fb47f342bb63e676479bddf26ebe1d"3const transferTx = {4 to: cdi.address,5 data: "0x02" + destAddr.slice(2, 42) + "0100",6}7await (await signer.sendTransaction(transferTx)).wait()Como o contrato ERC-20 confia no proxy (cdi), não precisamos de uma permissão para retransmitir transferências.
1// aprovação e transferirDe2const approveTx = {3 to: cdi.address,4 data: "0x03" + poorSigner.address.slice(2, 42) + "00FF",5}6await (await signer.sendTransaction(approveTx)).wait()78const destAddr2 = "0xE1165C689C0c3e9642cA7606F5287e708d846206"910const transferFromTx = {11 to: cdi.address,12 data: "0x04" + signer.address.slice(2, 42) + destAddr2.slice(2, 42) + "00FF",13}14await (await poorSigner.sendTransaction(transferFromTx)).wait()1516// Verifique se a combinação de aprovação / transferirDe foi feita corretamente17expect(await token.balanceOf(destAddr2)).to.equal(255)Exibir tudoTeste as duas novas funções.
Observe que transferFromTx requer dois parâmetros de endereço: o doador da permissão e o receptor.
Conclusão
Tanto a Optimism (opens in a new tab) quanto a Arbitrum (opens in a new tab) estão procurando maneiras de reduzir o tamanho do calldata escrito na L1 e, portanto, o custo das transações. No entanto, como provedores de infraestrutura em busca de soluções genéricas, nossas habilidades são limitadas. Como desenvolvedor de dapps, você tem conhecimento específico da aplicação, o que permite otimizar seu calldata muito melhor do que poderíamos em uma solução genérica. Esperamos que este artigo o ajude a encontrar a solução ideal para suas necessidades.
Veja aqui mais do meu trabalho (opens in a new tab).
Última atualização da página: 22 de agosto de 2025