Pular para o conteúdo principal

ABIs curtos para otimização de Calldata

camada 2
Intermediário
Ori Pomerantz
1 de abril de 2022
15 minutos de leitura

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:

  1. Processamento L2, que geralmente é extremamente barato
  2. 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çãoComprimentoBytesBytes desperdiçadosGás desperdiçadoBytes necessáriosGás necessário
Seletor de função40-3348116
Zeros124-15124800
Endereço de destino2016-350020320
Quantidade3236-67176415240
Total68160576

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 usar
3 */
4 function faucet() external {
5 _mint(msg.sender, 1000);
6 } // function faucet

CalldataInterpreter.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: Unlicense
2pragma solidity ^0.8.0;
3
4
5import { OrisUselessToken } from "./Token.sol";

Precisamos da função do token para saber como chamá-la.

1contract CalldataInterpreter {
2
3 OrisUselessToken public immutable token;

O endereço do token para o qual somos um proxy.

1
2 /**
3 * @dev Especifique o endereço do token
4 * @param tokenAddr_ Endereço do contrato ERC-20
5 */
6 constructor(
7 address tokenAddr_
8 ) {
9 token = OrisUselessToken(tokenAddr_);
10 } // constructor
Exibir tudo

O 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;
2
3 require(length < 0x21,
4 "calldataVal length limit is 32 bytes");
5
6 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>...).

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

1
2 return _retVal;
3 }
4
5
6 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;
2
3 _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:

  1. Funções que são pure ou view nã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.
  2. Funções que dependem de msg.sender (opens in a new tab). O valor de msg.sender será o endereço do CalldataInterpreter, 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).

1
2 // Chame os métodos de alteração de estado do token usando
3 // informações do calldata
4
5 // faucet
6 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çãoComprimentoBytes
Seletor de função10
Endereço de destino321-32
Quantidade233-34
1 } // fallback
2
3} // contract CalldataInterpreter

test.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");
2
3describe("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)
9
10 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)
14
15 const signer = await ethers.getSigner()
Exibir tudo

Começamos implantando ambos os contratos.

1 // Obter tokens para usar
2 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:

  1. to, o endereço de destino. Este é o contrato interpretador de calldata.
  2. data, o calldata a ser enviado. No caso de uma chamada de faucet, os dados são um único byte, 0x01.
1
2 }
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 corretamente
2expect(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 tokens
2const 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()
2
3 // Verifique se temos 256 tokens a menos
4 expect (await token.balanceOf(signer.address)).to.equal(1000-256)
5
6 // E que nosso destino os recebeu
7 expect (await token.balanceOf(destAddr)).to.equal(256)
8 }) // it
9}) // describe
Exibir tudo

Reduzindo 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 CalldataInterpreter
2 address owner;
3
4 // O endereço do CalldataInterpreter
5 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ário
4 */
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");
8
9 proxy = _proxy;
10 } // function setProxy
Exibir tudo

O 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 */
2
3 function transferProxy(address from, address to, uint256 amount)
4 public virtual onlyProxy() returns (bool)
5 {
6 _transfer(from, to, amount);
7 return true;
8 }
9
10 function approveProxy(address from, address spender, uint256 amount)
11 public virtual onlyProxy() returns (bool)
12 {
13 _approve(from, spender, amount);
14 return true;
15 }
16
17 function transferFromProxy(
18 address spender,
19 address from,
20 address to,
21 uint256 amount
22 ) public virtual onlyProxy() returns (bool)
23 {
24 _spendAllowance(from, spender, amount);
25 _transfer(from, to, amount);
26 return true;
27 }
Exibir tudo

Estas 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:

  1. É modificada por onlyProxy() para que ninguém mais possa controlá-las.
  2. Recebe o endereço que normalmente seria msg.sender como 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 }
9
10 // aprovar
11 if (_func == 3) {
12 token.approveProxy(
13 msg.sender,
14 address(uint160(calldataVal(1, 20))),
15 calldataVal(21, 2)
16 );
17 }
18
19 // transferirDe
20 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 tudo

Test.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)
2
3// Precisa de dois assinantes para verificar as permissões
4const 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 tokens
2const 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 transferirDe
2const approveTx = {
3 to: cdi.address,
4 data: "0x03" + poorSigner.address.slice(2, 42) + "00FF",
5}
6await (await signer.sendTransaction(approveTx)).wait()
7
8const destAddr2 = "0xE1165C689C0c3e9642cA7606F5287e708d846206"
9
10const 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()
15
16// Verifique se a combinação de aprovação / transferirDe foi feita corretamente
17expect(await token.balanceOf(destAddr2)).to.equal(255)
Exibir tudo

Teste 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

Este tutorial foi útil?