Pular para o conteúdo principal

ABIs curtos para otimização de dados de chamada

camada 2
Intermediário
✍️Ori Pomerantz
📆 1 de abril de 2022
⏱️15 minutos de leitura minute read

Introdução

Neste artigo, você aprenderá sobre optimistic rollups, os custos das transações e como essa estrutura de custos diferente nos obriga a otimizar coisas diferentes do que fazemos na Ethereum Mainnet. Você também aprenderá como implementar essa otimização.

Divulgação completa

Eu sou funcionário em tempo integral da Optimism(opens in a new tab), então os exemplos neste artigo serão executados na Optimism. No entanto, a técnica explicada aqui deve funcionar para outras rollups também.

Terminologia

Quando se discute rollups, o termo 'camada 1' (L1) é usado para a Mainnet, a rede Ethereum de produção. O termo 'camada 2' (L2) é usado para a rollup ou qualquer outro sistema que depende do L1 para segurança, mas faz a maior parte de seu processamento fora da cadeia

Como podemos reduzir ainda mais o custo das transações L2?

Optimistic rollups tem que preservar um registro de cada transação histórica para que qualquer pessoa possa passar por elas e verificar se o estado atual está correto. A forma mais barata de obter dados na Ethereum Mainnet é escrevê-los como calldata. Esta solução foi escolhida por ambos Optimism(opens in a new tab) e 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, vinculado aos custos de gas da Mainnet

No momento em que escrevo isso, no Optimism, o custo do gas L2 é de 0,001 Gwei. O custo do gas na L1 é de aproximadamente 40 gwei. Você pode ver os preços atuais aqui(opens in a new tab).

Um byte de dado da chamada custa, ou 4 gas (se for zero), ou 16 gas (se for qualquer outro valor). Uma das operações mais caras no EVM é escrever no storage. O custo máximo de escrever uma palavra de 32 bytes para armazenamento na L2 é de 22100 gas. Atualmente, isso é 22.1 gwei. Portanto, se nós pudermos salvar um único byte zero de calldata, poderemos gravar cerca de 200 bytes no armazenamento e ainda sairemos ganhando.

O 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 do aplicativo (ABI)(opens in a new tab).

No entanto, a ABI foi projetada para L1, em que um byte de dados da chamada custa aproximadamente o mesmo que quatro operações aritméticas, não para L2, em que um byte de dados da chamada custa mais de mil operações aritméticas. Por exemplo, aqui está uma transação de transferência ERC-20(opens in a new tab). Os dados da chamada são divididos da seguinte forma:

SeçãoComprimentoBytesBytes gastosGas gastoBytes necessáriosGas necessário
Seletor de função40-3348116
Zeros124-15124800
Endereço de destino2016-350020320
Quantidade3216-35176415240
Total68576576

Explicação:

  • Seletor de funções: O contrato tem menos de 256 funções, portanto podemos distingui-las com um único byte. Esses bytes são tipicamente diferentes de zero e, portanto, custam dezesseis 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 usá-lo. Bytes que possuem zero custam quatro gas (consulte o yellow paper(opens in a new tab), Apêndice G, pág. 27, o valor para Gtxdatazero).
  • Quantia: Se nós assumirmos que neste contrato decimais são dezoito (o valor normal) e o valor máximo de tokens que nós transferimos será 1018, nós temos uma quantia máxima de 1036. 25615 > 1036, então quinze bytes são suficientes.

Um gasto de 160 gas na L1 é normalmente insignificante. Uma transação custa pelo menos 21.000 gas(opens in a new tab), então um extra de 0,8% não importa. Entretanto, na L2, as coisas são diferentes. Quase o custo inteiro da transação é escrevendo-o na L1. Em adição ao calldata da transação, há 109 bytes de cabeçalho de transação (endereço de destino, assinatura, etc.). O custo total é portanto 109*16+576+160=2480, e nós estamos desperdiçando cerca de 6,5% disso.

Reduzindo custos quando você não controla o destino

Assumindo que você não tem controle sobre o contrato de destino, você pode ainda usar uma solução similar a esta(opens in a new tab). Vamos passar pelos arquivos relevantes.

Token.sol

Este é o contrato destino(opens in a new tab). É um contrato ERC-20 padrão, com um recurso adicional. Esta função faucet permite qualquer usuário obter algum token para usar. Ele faria o contrato de produção ERC-20 inútil, mas ele facilita a vida quando um ERC-20 existe somente para facilitar o teste.

1 /**
2 * @dev Gives the caller 1000 tokens to play with
3 */
4 function faucet() external {
5 _mint(msg.sender, 1000);
6 } // function faucet
📋 Copiar

Você pode ver um exemplo deste contrato sendo implantado aqui(opens in a new tab).

CalldataInterpreter.sol

Este é o contrato que transações devem chamar com calldata menor(opens in a new tab). Vamos passar por ele linha a linha.

1//SPDX-License-Identifier: Unlicense
2pragma solidity ^0.8.0;
3
4
5import { OrisUselessToken } from "./Token.sol";
📋 Copiar

Nós precisamos da função do token para saber como chamá-lo.

1contract CalldataInterpreter {
2
3 OrisUselessToken public immutable token;
📋 Copiar

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

1
2 /**
3 * @dev Specify the token address
4 * @param tokenAddr_ ERC-20 contract address
5 */
6 constructor(
7 address tokenAddr_
8 ) {
9 token = OrisUselessToken(tokenAddr_);
10 } // constructor
Exibir tudo
📋 Copiar

O endereço do token é o único parâmetro que nós precisamos especificar.

1 function calldataVal(uint startByte, uint length)
2 private pure returns (uint) {
📋 Copiar

Ler 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");
📋 Copiar

Nós iremos carregar uma simples palavra de 32-bytes (256-bit) para a memória e remover os bytes que não são parte do campo que nós queremos. Este algoritmo não funciona para valores maiores que 32 bytes, e claro, não podemos ler depois do fim do calldata. Na L1 pode ser necessário pular estes testes para economizar gas, mas na L2 o gas é extremamente barato, o que permite qualquer checagem de sanidade que possamos pensar.

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

Nós poderiamos ter copiado os dados da chamada ao fallback() (veja abaixo), mas é mais fácil usar Yul(opens in a new tab), a linguagem de montagem da EVM.

Aqui nós usamos o opcode CALLDATALOAD(opens in a new tab) para ler bytes startByte até 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);
📋 Copiar

Somente os bytes mais length significantes são parte do campo, então nós fazemos right-shift(opens in a new tab) para se livrar dos outros valores. Isto tem a vantagem adicional de mover o valor para a direita do campo, então é o valor por ele mesmo, ao invés do valor vezes 256alguma coisa.

1
2 return _retVal;
3 }
4
5
6 fallback() external {
📋 Copiar

Quando uma chamada a um contrato Solidity não encontra nenhuma das assinaturas de função, ela chama a função the fallback()(opens in a new tab) (assumindo que exista uma). No caso de CalldataInterpreter, qualquer chamada chega aqui porque não há outras funções external ou public.

1 uint _func;
2
3 _func = calldataVal(0, 1);
📋 Copiar

Leia o primeiro byte do calldata, que nos conta a função. Há duas razões porque uma função não estaria disponível aqui:

  1. Funções que são pure ou view não mudam seu estado e não custam gas (quando chamadas off-chain). Não faz sentido tentar reduzir seus custos de gas.
  2. Funções que confiam em msg.sender(opens in a new tab). O valor de msg.sender será o endereço do CalldataInterpreter, não o chamador.

Infelizmente, olhando as especificações do ERC-20(opens in a new tab), isto deixa apenas uma função, transfer. Isto nos deixa com somente duas funções: transfer (porque nós podemos chamar transferFrom) e faucet (porque nós podemos transferir os tokens de volta a quem quer tenha nos chamado).

1
2 // Call the state changing methods of token using
3 // information from the calldata
4
5 // faucet
6 if (_func == 1) {
📋 Copiar

Uma chamada para faucet(), que não tem parâmetros.

1 token.faucet();
2 token.transfer(msg.sender,
3 token.balanceOf(address(this)));
4 }
📋 Copiar

Depois que nós chamamos token.faucet() nós obtivemos tokens. Entretanto, como o contrato proxy, nós não precisamos de tokens. O EOA (externally owned account) ou contrato que nos chamou o faz. Então nós transferimos todos nossos tokens para quem quer tenha nos chamado.

1 // transfer (assume we have an allowance for it)
2 if (_func == 2) {
📋 Copiar

Transferir tokens requer dois parâmetros: o endereço de destino e a quantidade.

1 token.transferFrom(
2 msg.sender,
📋 Copiar

Nós apenas permitimos chamadores transferir tokens que eles possuam

1 address(uint160(calldataVal(1, 20))),
📋 Copiar

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)
📋 Copiar

Para esse contrato em particular nós assumimos que o número máximo de tokens que qualquer um poderia querer transferir cabe em dois bytes (menos que 65536).

1 );
2 }
📋 Copiar

Em geral, uma transferência pega 35 bytes de calldata:

SeçãoComprimentoBytes
Seletor de função10
Endereço de destino321-32
Quantidade233-34
1 } // fallback
2
3} // contract CalldataInterpreter
📋 Copiar

test.js

Este teste unitário JavaScript(opens in a new tab) nos mostra como usar este mecanismo (e como verificar que ele trabalha corretamente). Parto do princípio que você entendeu chai(opens in a new tab) e ethers(opens in a new tab) e apenas explicar as partes que especificamente se aplicam ao contrato.

1const { expect } = require("chai");
2
3describe("CalldataInterpreter", function () {
4 it("Should let us use 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
📋 Copiar

Nós começamos por implantar ambos contratos.

1 // Get tokens to play with
2 const faucetTx = {

Nós não podemos usar funções de alto nível que nós normalmente usamos (como token.faucet()) para criar transações, porque nós não seguimos o ABI. Ao invés disso, nós temos que construir a transação nós mesmos e enviá-la.

1 to: cdi.address,
2 data: "0x01"

Há dois parâmetros que nós precisamos fornecer para a transação:

  1. to, o endereço de destino. Isto é o contrato interpretador do calldata.
  2. data, o calldata a enviar. No caso de uma chamada de faucet, o dado é um único byte, 0x01.
1
2 }
3 await (await signer.sendTransaction(faucetTx)).wait()

Nós chamamos o método sendTransaction do assinante(opens in a new tab) porque nós já especificamos o destino (faucetTx.to) e nós precisamos que a transação seja assinada.

1// Check the faucet provides the tokens correctly
2expect(await token.balanceOf(signer.address)).to.equal(1000)

Aqui nós verificamos o saldo. Não há necessidade de economizar gas em funções view, então nós só as rodamos normalmente.

1// Give the CDI an allowance (approvals cannot be proxied)
2const approveTX = await token.approve(cdi.address, 10000)
3await approveTX.wait()
4expect(await token.allowance(signer.address, cdi.address)).to.equal(10000)

Dar ao intérprete calldata uma permissão para ser capaz de fazer transferências.

1// Transfer tokens
2const destAddr = "0xf5a6ead936fb47f342bb63e676479bddf26ebe1d"
3const transferTx = {
4 to: cdi.address,
5 data: "0x02" + destAddr.slice(2, 42) + "0100",
6}

Criar uma transação de transferência. O primeiro byte é "0x02", seguido pelo endereço de destino, e finalmente a quantia (0x0100, que é 256 em decimal).

1 await (await signer.sendTransaction(transferTx)).wait()
2
3 // Check that we have 256 tokens less
4 expect (await token.balanceOf(signer.address)).to.equal(1000-256)
5
6 // And that our destination got them
7 expect (await token.balanceOf(destAddr)).to.equal(256)
8 }) // it
9}) // describe
Exibir tudo

Exemplo

Se você quiser ver estes arquivos em ação sem precisar rodá-los, siga estes links:

  1. Implantação de OrisUselessToken(opens in a new tab) no endereço 0x950c753c0edbde44a74d3793db738a318e9c8ce8(opens in a new tab).
  2. Implantação de CalldataInterpreter(opens in a new tab) no endereço 0x16617fea670aefe3b9051096c0eb4aeb4b3a5f55(opens in a new tab).
  3. Chamada para faucet()(opens in a new tab).
  4. Chamada para OrisUselessToken.approve()(opens in a new tab). Esta chamada tem de ir diretamente para o contrato do token porque o processamento confia no msg.sender.
  5. Chamada para transfer()(opens in a new tab).

Reduzindo o custo quando você controla o contrato destino

Se você realmente tem controle sobre o contrato destino, você pode criar funções que ignoram as checagens do msg.sender porque eles acreditam no intérprete do calldata. Você pode ver um exemplo de como isto funciona aqui, no branch control-contract(opens in a new tab).

Se o contrato estiver respondendo somente para transações externas, nós poderíamos ter apenas um contrato. Entretanto, isso iria quebrar a capacidade de composição. É bem melhor ter um contrato que responda a chamadas ERC-20 normais, e outro contrato que responda a transações com chamadas curtas de dados.

Token.sol

Neste exemplo nós podemos modificar Token.sol. Isto nos deixa ter um número de funções que somente o proxy pode chamar. Eis aqui as novas partes:

1 // The only address allowed to specify the CalldataInterpreter address
2 address owner;
3
4 // The CalldataInterpreter address
5 address proxy = address(0);
📋 Copiar

O contrato ERC-20 precisa saber a identidade do proxy autorizado. Entretanto, nós não podemos configurar esta variável no construtor, porque nós não sabemos o valor ainda. Este contrato é instanciado primeiro porque o proxy espera o endereço do token no seu construtor.

1 /**
2 * @dev Calls the ERC20 constructor.
3 */
4 constructor(
5 ) ERC20("Oris useless token-2", "OUT-2") {
6 owner = msg.sender;
7 }
📋 Copiar

O endereço do criador (chamadoowner) é armazenado aqui porque este é o único endereço permitido para configurar o proxy.

1 /**
2 * @dev set the address for the proxy (the CalldataInterpreter).
3 * Can only be called once by the owner
4 */
5 function setProxy(address _proxy) external {
6 require(msg.sender == owner, "Can only be called by owner");
7 require(proxy == address(0), "Proxy is already set");
8
9 proxy = _proxy;
10 } // function setProxy
Exibir tudo
📋 Copiar

O proxy tem acesso privilegiado, porque ele pode ignorar checagens de segurança. Para garantir que nós podemos acreditar no proxy, nós somente deixamos owner chamar esta função, e somente uma vez. Uma vez que proxy tenha um valor real (não zero), este valor não pode mudar, então mesmo se o proprietário decide se tornar trapaceiro, ou caso o mnemônico seja revelado a ele, nós ainda estamos seguros.

1 /**
2 * @dev Some functions may only be called by the proxy.
3 */
4 modifier onlyProxy {
📋 Copiar

Isto é uma função modifier(opens in a new tab), ela modifica a maneira que outras funções trabalham.

1 require(msg.sender == proxy);
📋 Copiar

Primeiro, verifique que nós fomos chamados pelo proxy e ninguém mais. Se não, revert.

1 _;
2 }
📋 Copiar

Neste caso, rode a função que nós modificamos.

1 /* Functions that allow the proxy to actually proxy for accounts */
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
📋 Copiar

Há três operações que normalmente requerem que a mensagem venha direto da entidade transferindo tokens ou aprovando uma permissão. Aqui nós temos uma versão de proxy destas operações que:

  1. É modificada pelo onlyProxy(), de modo que ninguém mais tem permissão de controlá-los.
  2. Pega o endereço que seria normalmente msg.sender como um parâmetro extra.

CalldataInterpreter.sol

O interpretador calldata é praticamente idêntico ao acima, exceto que as funções com proxy recebem um parâmetro msg.sender e não há necessidade de permissão transfer.

1 // transfer (no need for allowance)
2 if (_func == 2) {
3 token.transferProxy(
4 msg.sender,
5 address(uint160(calldataVal(1, 20))),
6 calldataVal(21, 2)
7 );
8 }
9
10 // approve
11 if (_func == 3) {
12 token.approveProxy(
13 msg.sender,
14 address(uint160(calldataVal(1, 20))),
15 calldataVal(21, 2)
16 );
17 }
18
19 // transferFrom
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
📋 Copiar

Test.js

Há pequenas mudanças 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)
📋 Copiar

Nós precisamos contar ao contrato ERC-20 qual proxy acreditar

1console.log("CalldataInterpreter addr:", cdi.address)
2
3// Need two signers to verify allowances
4const signers = await ethers.getSigners()
5const signer = signers[0]
6const poorSigner = signers[1]
📋 Copiar

Para checar approve() e transferFrom() nós precisamos de um segundo assinante. Nós o chamamos de poorSigner porque ele não pega nenhum de nossos tokens (ele precisa ter ETH, claro).

1// Transfer 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()
📋 Copiar

Como o contrato ERC-20 confia no proxy (cdi), nós não precisamos de uma permissão para confiar em transferências.

1// approval and transferFrom
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// Check the approve / transferFrom combo was done correctly
17expect(await token.balanceOf(destAddr2)).to.equal(255)
Exibir tudo
📋 Copiar

Teste as duas novas funções. Note que transferFromTx requer dois parâmetros de endereço: o que deu a permissão e o recebedor.

Exemplo

Se você quiser ver estes arquivos em ação sem precisar rodá-los, siga estes links:

  1. Implantação de OrisUselessToken-2(opens in a new tab) no endereço 0xb47c1f550d8af70b339970c673bbdb2594011696(opens in a new tab).
  2. Implantação de CalldataInterpreter(opens in a new tab) no endereço 0x0dccfd03e3aaba2f8c4ea4008487fd0380815892(opens in a new tab).
  3. Chamada para transfer()(opens in a new tab).
  4. Chamada para faucet()(opens in a new tab).
  5. Chamada para transferProxy()(opens in a new tab).
  6. Chamada para approveProxy()(opens in a new tab).
  7. Chamada para transferFromFProxy()(opens in a new tab). Note que esta chamada vem de um endereço diferente dos outros, poorSigner ao invés de signer.

Conclusão

Ambos Optimism(opens in a new tab) e Arbitrum(opens in a new tab) estão procurando por maneiras de reduzir o tamanho do calldata escrito no L1 e portanto o custo das transações. Entretanto, como provedores de infraestrutura procurando por soluções genéricas, nossas habilidades são limitadas. Como desenvolvedor dapp, você tem conhecimento específico de aplicações, o que te leva a otimizar seu calldata muito melhor do que nós poderíamos com uma solução genérica. Esperamos que este artigo ajude você a encontrar a solução ideal para as suas necessidades.

Última edição: @, 19 de janeiro de 2024

Este tutorial foi útil?