ABIs curtos para otimização de dados de chamada
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:
- Processamento L2, que geralmente é extremamente barato
- 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ção | Comprimento | Bytes | Bytes gastos | Gas gasto | Bytes necessários | Gas 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 | 16-35 | 17 | 64 | 15 | 240 |
Total | 68 | 576 | 576 |
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
G
txdatazero
). - 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 with3 */4 function faucet() external {5 _mint(msg.sender, 1000);6 } // function faucetCopiar
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: Unlicense2pragma solidity ^0.8.0;345import { OrisUselessToken } from "./Token.sol";Copiar
Nós precisamos da função do token para saber como chamá-lo.
1contract CalldataInterpreter {23 OrisUselessToken public immutable token;Copiar
O endereço do token para o qual nós somos um proxy.
12 /**3 * @dev Specify the token address4 * @param tokenAddr_ ERC-20 contract address5 */6 constructor(7 address tokenAddr_8 ) {9 token = OrisUselessToken(tokenAddr_);10 } // constructorExibir tudoCopiar
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;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");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>...)
.
12 _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.
12 return _retVal;3 }456 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;23 _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:
- Funções que são
pure
ouview
não mudam seu estado e não custam gas (quando chamadas off-chain). Não faz sentido tentar reduzir seus custos de gas. - Funções que confiam em
msg.sender
(opens in a new tab). O valor demsg.sender
será o endereço doCalldataInterpreter
, 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).
12 // Call the state changing methods of token using3 // information from the calldata45 // faucet6 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ção | Comprimento | Bytes |
---|---|---|
Seletor de função | 1 | 0 |
Endereço de destino | 32 | 1-32 |
Quantidade | 2 | 33-34 |
1 } // fallback23} // contract CalldataInterpreterCopiar
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");23describe("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)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 tudoCopiar
Nós começamos por implantar ambos contratos.
1 // Get tokens to play with2 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:
to
, o endereço de destino. Isto é o contrato interpretador do calldata.data
, o calldata a enviar. No caso de uma chamada de faucet, o dado é um único byte,0x01
.
12 }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 correctly2expect(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 tokens2const 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()23 // Check that we have 256 tokens less4 expect (await token.balanceOf(signer.address)).to.equal(1000-256)56 // And that our destination got them7 expect (await token.balanceOf(destAddr)).to.equal(256)8 }) // it9}) // describeExibir tudo
Exemplo
Se você quiser ver estes arquivos em ação sem precisar rodá-los, siga estes links:
- Implantação de
OrisUselessToken
(opens in a new tab) no endereço0x950c753c0edbde44a74d3793db738a318e9c8ce8
(opens in a new tab). - Implantação de
CalldataInterpreter
(opens in a new tab) no endereço0x16617fea670aefe3b9051096c0eb4aeb4b3a5f55
(opens in a new tab). - Chamada para
faucet()
(opens in a new tab). - Chamada para
OrisUselessToken.approve()
(opens in a new tab). Esta chamada tem de ir diretamente para o contrato do token porque o processamento confia nomsg.sender
. - 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 address2 address owner;34 // The CalldataInterpreter address5 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 owner4 */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");89 proxy = _proxy;10 } // function setProxyExibir tudoCopiar
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 */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 tudoCopiar
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:
- É modificada pelo
onlyProxy()
, de modo que ninguém mais tem permissão de controlá-los. - 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 }910 // approve11 if (_func == 3) {12 token.approveProxy(13 msg.sender,14 address(uint160(calldataVal(1, 20))),15 calldataVal(21, 2)16 );17 }1819 // transferFrom20 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 tudoCopiar
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)23// Need two signers to verify allowances4const 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 tokens2const 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 transferFrom2const 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// Check the approve / transferFrom combo was done correctly17expect(await token.balanceOf(destAddr2)).to.equal(255)Exibir tudoCopiar
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:
- Implantação de
OrisUselessToken-2
(opens in a new tab) no endereço0xb47c1f550d8af70b339970c673bbdb2594011696
(opens in a new tab). - Implantação de
CalldataInterpreter
(opens in a new tab) no endereço0x0dccfd03e3aaba2f8c4ea4008487fd0380815892
(opens in a new tab). - Chamada para
transfer()
(opens in a new tab). - Chamada para
faucet()
(opens in a new tab). - Chamada para
transferProxy()
(opens in a new tab). - Chamada para
approveProxy()
(opens in a new tab). - Chamada para
transferFromFProxy()
(opens in a new tab). Note que esta chamada vem de um endereço diferente dos outros,poorSigner
ao invés designer
.
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: @Shiva-Sai-ssb(opens in a new tab), 30 de junho de 2024