WEB3DEV

Cover image for Dominando o Delegatecall em Solidity: Um Guia Completo com Análise Passo a Passo da EVM
Banana Labs
Banana Labs

Posted on

Dominando o Delegatecall em Solidity: Um Guia Completo com Análise Passo a Passo da EVM

capa

Neste artigo, você terá uma visão geral abrangente do delegatecall em Solidity, como funciona e exemplos reais de seus casos de uso. Também passaremos por um guia passo a passo para usar o delegatecall com uma análise detalhada da EVM, destacando armadilhas comuns e como evitá-las. Sem mais delongas, vamos começar.

Pré-requisito: Antes de se aprofundar neste artigo, certifique-se de que você compreenda como funciona o layout de armazenamento de variáveis de estado em Solidity. Caso contrário, certifique-se de verificar a documentação sobre isso https://docs.soliditylang.org/en/v0.8.19/internals/layout_in_storage.html

O que é Delegatecall?

Em Solidity, o delegatecall é uma função de baixo nível que permite que um contrato delegue sua chamada a outro contrato (emprestando a funcionalidade de outro contrato) enquanto ainda preserva seu próprio armazenamento e contexto. Quando um contrato faz um delegatecall, o código no endereço de destino é executado no contexto do contrato que chama. Isso significa que o armazenamento, as variáveis de estado e as funções do contrato que chama estão acessíveis ao código em execução.

Para entender melhor como o delegatecall funciona, vamos considerar o seguinte cenário:
Imagine que você é um empreiteiro👷 e foi contratado para construir uma casa🏠. Você é responsável por coordenar com vários outros empreiteiros que serão responsáveis por diferentes aspectos do projeto, como encanamento, elétrica, carpintaria, etc.

Neste cenário, você é como o contrato que chama e os outros empreiteiros são como os contratos que você chamará usando o delegatecall.

Agora, imagine que você precise do encanador para entrar e instalar os canos na casa. Em vez de fazer isso você mesmo, você pode usar o delegatecall para chamar o encanador e fazê-lo instalar os canos para você. Quando você usa o delegatecall, o encanador vem ao seu canteiro de obras e instala os canos no contexto do seu projeto, em vez do dele. Isso significa que o encanador pode acessar as ferramentas, materiais e trabalhadores que você tem disponíveis, em vez de ter que trazer os próprios. No entanto, o encanador ainda usará sua própria experiência e conhecimento para instalar os canos corretamente.

Da mesma forma, quando você usa o delegatecall em um contrato inteligente, pode chamar outro contrato e executar seu código no contexto do seu próprio contrato. Isso permite que você acesse seu próprio armazenamento, variáveis de estado e funções enquanto ainda é capaz de executar o código do outro contrato.

A Diferença entre Call e DelegateCall

Vamos fazer uma pausa por um momento e examinar a distinção entre utilizar call e delegatecall em Solidity. É importante compreender a diferença entre esses dois, pois ela é crucial para atualizar contratos inteligentes.

diagrama

call é uma função de baixo nível do Solidity usada para fazer uma chamada externa. Quando call é usado, o contrato alvo é executado em seu próprio contexto e qualquer alteração de estado que ele faça é limitada ao seu próprio armazenamento. call também retorna um valor booleano para indicar se a chamada foi bem-sucedida ou não.

delegatecall é similar ao call, mas, em vez de executar o contrato alvo em seu próprio contexto, ele o executa no contexto do estado atual do contrato chamador, como você pode ver no diagrama acima. Isso significa que qualquer alteração de estado feita pelo contrato alvo é feita no armazenamento do contrato chamador. Esse caso de uso específico tem grande importância ao atualizar um contrato, pois permite separar a lógica do contrato em vários contratos sem perder o estado original do contrato.

Por exemplo, suponha que você tenha um contrato que precisa ser atualizado. Você pode criar um novo contrato com a lógica atualizada e usar delegatecall para executar o código do novo contrato no contexto do armazenamento do contrato original. Dessa forma, a lógica atualizada pode acessar e modificar o estado do contrato original conforme necessário.

Coisas a serem observadas ao usar delegatecall

  • Quando um contrato faz um delegatecall, o valor de address(this), msg.sender e msg.value não alteram seus valores. No entanto, é importante notar que, embora o contrato alvo possa acessar as variáveis de armazenamento do contrato chamador, ele não herda os parâmetros de chamada de mensagem originais do contrato chamador. Os parâmetros de chamada de mensagem originais incluem msg.sender, que é o endereço da conta que enviou a chamada de mensagem original, e msg.value, que é a quantidade de Ether enviada junto com a chamada de mensagem, se houver.

  • Quando um contrato é chamado usando delegatecall, os valores de msg.sender e msg.value passados para o contrato alvo não são aqueles do delegatecall, mas sim aqueles da chamada de mensagem original para o contrato chamador. Isso significa que o contrato alvo não pode acessar o endereço real da conta que enviou o delegatecall ou a quantidade de Ether enviada com o delegatecall.

Para ilustrar este ponto, vamos supor que o Contrato A chame o Contrato B usando delegatecall. Se o Contrato B acessar msg.sender ou msg.value, ele recuperará os valores da chamada de mensagem original para o Contrato A, não aqueles do delegatecall do Contrato A para o Contrato B. Isso ocorre porque delegatecall encaminha toda a mensagem (incluindo msg.sender e msg.value) para o contrato alvo, mas executa o código do contrato chamado no contexto do contrato chamador.

  • Quando uma função é executada com delegatecall, leituras e gravações nas variáveis de estado não acontecem no contrato que carrega e executa a função, ou seja, o contrato alvo. Em vez disso, as leituras e gravações acontecem no contrato que define a função sendo executada (contrato chamador). Isso ocorre porque o contexto de execução é o do contrato alvo, não o do contrato chamador. Portanto, quaisquer alterações feitas nas variáveis de estado dentro da função alvo acontecerão no armazenamento do contrato alvo, não no contrato chamador.

Por exemplo, quando o Contrato A usa delegatecall para executar uma função do Contrato B, o contexto de execução ainda está dentro do Contrato A. Isso significa que qualquer leitura ou gravação de variáveis de estado dentro desse contexto será para as variáveis de estado do Contrato A, não do Contrato B. As variáveis de estado do Contrato B estão efetivamente "ocultas" do contexto de execução do Contrato A e não podem ser lidas ou gravadas diretamente. No entanto, as funções do Contrato B ainda podem acessar e modificar suas próprias variáveis de estado, desde que sejam chamadas diretamente (ou seja, não por meio de delegatecall) ou por meio de outro contrato que usa call em vez de delegatecall.

Como usar delegatecall com exemplo de código

No código abaixo, o CallingContract possui o seguinte:

  • Uma variável de estado configurada como string "Something".
  • Uma função set que recebe um endereço _calledAddress como argumento. Este endereço deve corresponder ao endereço do contrato TargetContract. A função set então usa delegatecall para chamar a função changeMessage do TargetContract, passando o argumento "He is Alive" (“Ele está Vivo”) como uma string quando chamado.
  • A função changeMessage do TargetContract atualiza as variáveis de estado message e owner, e as alterações são refletidas no CallingContract.
  • Se a função delegatecall falhar, a função set será revertida e retornará uma mensagem de erro. A mensagem de erro indicará se a chamada da função foi revertida ou se houve outro erro.

O TargetContract, que possui a funcionalidade a ser implementada pelo CallingContract usando delegatecall, possui o seguinte:

  • O contrato tem duas variáveis de estado, message e owner, ambas declaradas como públicas, o que significa que podem ser acessadas por qualquer pessoa. A variável message é inicializada com a string "I love solidity" (“Eu amo solidity”), enquanto a variável owner é inicializada com o valor passado ao construtor quando o contrato é implantado.
  • O construtor do contrato recebe um argumento _owner, que é um endereço que representa o proprietário do contrato. Este argumento é usado para inicializar a variável owner.
  • O contrato também possui uma função chamada changeMessage, que recebe um argumento de string _msg e atualiza as variáveis message e owner. A variável message é configurada para o valor de _msg, enquanto a variável owner é configurada para o endereço do chamador da função, que é obtido usando msg.sender.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.9;

contract CallingContract {
    string public message = "Something";

    address public owner = 0xAb8483F64d9C6d1EcF9b849Ae677dD3315835cb2;

    function set(address _calledAddress) external {
        (bool success, bytes memory returndata) = _calledAddress.delegatecall(
            abi.encodeWithSelector(
                TargetContract.changeMessage.selector,
                "He is Alive" // Ele está vivo
            )
        );

        // se a chamada de função for revertida
        if (success == false) {
            // se houver uma string de razão de retorno
            if (returndata.length > 0) {
                // borbulhe qualquer motivo para reverter
                assembly {
                    let returndata_size := mload(returndata)
                    revert(add(32, returndata), returndata_size)
                }
            } else {
                revert("Chamada de função revertida");
            }
        }
    }
}

contract TargetContract {
    string public message = "I love solidity"; // Eu amo solidity
    address public owner; //0x637CcDeBB20f849C0AA1654DEe62B552a058EA87

    //address(this) => 0x4401f7E80aDB3D7589E720e069CB7F81E0402550
    constructor(address _owner) {
        owner = _owner;
    }

    function changeMessage(string calldata _msg) external {
        message = _msg;
        owner = msg.sender;
    }

}
Enter fullscreen mode Exit fullscreen mode

O TargetContract tem o seguinte quando a função changeMessage() é chamada com um novo argumento:

  • address(this) -> 0x4401f7E80aDB3D7589E720e069CB7F81E0402550
  • msg.sender -> 0x637CcDeBB20f849C0AA1654DEe62B552a058EA87
  • message(passe qualquer argumento de string que você quiser) -> "Just updated" (“Apenas Atualize”)

Antes de a função set() no CallingContract ser chamada, o contrato tem o seguinte:

  • address(this) -> 0xB82a6d6aAFbeB1d994604C119B97272Cba504F4F
  • owner -> 0xAb8483F64d9C6d1EcF9b849Ae677dD3315835cb2
  • message -> "Something" (“Algo”)

Depois que a função set() no CallingContract é chamada, o contrato tem o seguinte:

  • address(this) -> 0xB82a6d6aAFbeB1d994604C119B97272Cba504F4F
  • msg.sender -> 0xEB7A41D324ee4859E3cbFAd4b3820B82FCCe6658
  • message -> "He is Alive" (“Ele está Vivo”)

Se você observar, o valor de address(this) não mudou e, como a variável owner foi atribuída ao chamador da função set(), que é o msg.sender, a variável de estado CallingContract é atualizada com o msg.sender atual no contexto do CallingContract e não do TargetContract.

remix ide

A variável "owner" no TargetContract ainda retorna um valor de 0x637CcDeBB20f849C0AA1654DEe62B552a058EA87, que é o mesmo que o msg.sender que implantou o TargetContract. Ao chamar a variável "owner" no CallingContract, ela retorna o valor do msg.sender que chamou a função "set()", que é "0xEB7A41D324ee4859E3cbFAd4b3820B82FCCe6658" neste contexto.
Assim, todos os requisitos mencionados em "Coisas a observar ao usar delegatecall" foram cumpridos.

Armadihas comuns a evitar ao usar delegatecall no Solidity:

  • Usar delegatecall para chamar um contrato desconhecido ou malicioso: Se você estiver usando delegatecall para chamar um contrato com o qual não está familiarizado, há um risco de que o contrato possa se comportar de maneira maliciosa ou ter vulnerabilidades que possam ser exploradas. Sempre se certifique de auditar cuidadosamente qualquer contrato que você esteja chamando com delegatecall.

  • Gerenciar o layout da variável de estado entre o contrato de chamada e o contrato alvo: Quando uma função é chamada com delegatecall, ela opera no contexto do armazenamento do contrato de chamada, o que significa que compartilha o mesmo espaço de endereço da variável de estado (posição do slot). Portanto, é importante que o contrato de chamada e o contrato alvo tenham o mesmo layout de variável de estado para quaisquer variáveis de estado que sejam lidas ou gravadas por ambos os contratos. Isso garante que ambos os contratos acessem as mesmas variáveis de estado na mesma ordem, evitando problemas como sobrescrever ou interpretar incorretamente as variáveis de estado um do outro. Ao ter o mesmo layout de variável de estado, ambos os contratos podem acessar os mesmos dados nas mesmas localizações de memória, independentemente de seus detalhes de implementação individuais.

Por exemplo, digamos que o Contract A declare variáveis de estado 'string message;' e 'address owner;' e o Contract B declare variáveis de estado 'uint256 num;' e 'address owner;'. Eles têm variáveis de estado diferentes na posição 0 ('string message' e 'uint256 num') no armazenamento do contrato e, portanto, escreverão e lerão dados incorretos entre eles na posição 0 se delegatecall for usado entre eles.

É importante planejar e gerenciar cuidadosamente o layout da variável de estado dos contratos que usam delegatecall para garantir que eles estejam acessando os mesmos dados nas mesmas localizações de memória. Isso pode ser alcançado por meio de estratégias como a definição de uma interface compartilhada ou comunicando minuciosamente qualquer alteração no layout da variável de estado. Para saber mais sobre como usar um armazenamento compartilhado para contratos atualizáveis, confira meu artigo anterior sobre o Diamond Standard: https://medium.com/@ajaotosinserah/upgradable-functionality-with-eip2535-a-comparative-analysis-c9c1d9954296

  • Alterando variáveis de estado no contrato de chamada: Ao usar delegatecall, você precisa ter cuidado para não modificar acidentalmente as variáveis de estado no contrato de chamada. Se a função chamada modificar variáveis de estado, essas alterações serão feitas no estado do contrato de chamada em vez do estado do contrato alvo. Sempre se certifique de usar call em vez de delegatecall se precisar modificar variáveis de estado no contrato de chamada.

  • É importante observar que, se delegatecall for chamado em um endereço que não seja um contrato (EOA) e, portanto, não tenha código, ele retornará um valor de status true (verdadeiro). Esse comportamento pode causar bugs se o código esperar que as funções delegatecall retornem false (falso) quando não puderem ser executadas. Para evitar esse problema, é aconselhável verificar se qualquer variável de endereço usada com delegatecall contém código antes de executar a função. Se houver alguma incerteza sobre se o endereço sempre conterá código, é importante verificar antes de usar delegatecall e reverter a transação se o endereço não contiver código. Aqui está um exemplo de código que verifica se um endereço possui código:

function hasCode(address _address) internal view returns (bool) {
    uint256 codeSize;
    assembly { codeSize := extcodesize(_address) }
    return codeSize > 0;
}


// Esta função utiliza o opcode extcodesize para verificar o tamanho do código no endereço fornecido.
// Se o tamanho do código for maior que zero, o endereço contém código e a função retorna verdadeiro.
// Caso contrário, retorna falso. Ao usar esta função para verificar a presença de código em um endereço
// antes de usar delegatecall, você pode ajudar a prevenir bugs e vulnerabilidades no seu código.
Enter fullscreen mode Exit fullscreen mode
  • Utilização incorreta do valor de retorno: delegatecall retorna um valor booleano indicando se a chamada foi bem-sucedida ou não. No entanto, o valor de retorno da função chamada não é automaticamente retornado. Isso pode levar a comportamentos inesperados se o valor de retorno não for tratado adequadamente. Sempre se certifique de lidar corretamente com o valor de retorno da função chamada.
  • Passando argumentos incorretos: ao usar delegatecall, você precisa garantir que os argumentos passados para a função chamada estejam corretos. Se os argumentos estiverem incorretos, a função chamada pode se comportar de maneira inesperada ou até mesmo falhar. Sempre se certifique de passar os argumentos corretos para a função chamada.

Para evitar essas armadilhas, é importante entender completamente como delegatecall funciona e usá-lo com critério. Certifique-se de testar seu código completamente e auditar todos os contratos que você está chamando com delegatecall.

Passeio pela EVM

Na discussão a seguir, revisaremos brevemente os opcodes usados no exemplo de código anterior e forneceremos uma explicação de como o OPCODE de delegatecall é utilizado, bem como o preço do gás.

146 JUMP 
147 JUMPDEST 
148 PUSH1 40 
150 MLOAD 
151 PUSH2 0082 
154 SWAP2 
155 SWAP1 
156 PUSH2 02a3 
159 JUMP 
160 JUMPDEST 
161 PUSH1 40 
163 DUP1 
164 MLOAD 
165 PUSH1 20 
167 PUSH1 24 
169 DUP3 
170 ADD 
171 DUP2 
172 SWAP1 
173 MSTORE 
174 PUSH1 0b 
176 PUSH1 44 
178 DUP4 
179 ADD 
180 MSTORE 
181 PUSH11 486520697320416c697665 
193 PUSH1 a8 
195 SHL 
196 PUSH1 64 
198 DUP1 
199 DUP5 
200 ADD 
201 SWAP2 
202 SWAP1
203 SWAP2
204 MSTORE 
205 DUP4 
206 MLOAD 
207 DUP1 
208 DUP5 
209 SUB 
210 SWAP1 
211 SWAP2 
212 ADD 
213 DUP2 
214 MSTORE 
215 PUSH1 84 
217 SWAP1 
218 SWAP3 
219 ADD 
220 DUP4 
221 MSTORE 
222 DUP2 
223 ADD 
224 DUP1
225 MLOAD 
226 PUSH1 01 
228 PUSH1 01 
230 PUSH1 e0 
232 SHL 
233 SUB 
234 AND 
235 PUSH4 60fd1c4f 
240 PUSH1 e0 
242 SHL 
243 OR 
244 SWAP1 
245 MSTORE 
246 SWAP1 
247 MLOAD 
248 PUSH1 00 
251 DUP3 
252 SWAP2 
253 PUSH1 01 
255 PUSH1 01 
257 PUSH1 a0 
259 SHL 
260 SUB 
261 DUP6 
262 AND 
263 SWAP2 
264 PUSH2 0110 
267 SWAP2 
268 PUSH2 02d6 
271 JUMP 
272 JUMPDEST 
273 PUSH1 00 
275 PUSH1 40 
277 MLOAD 
278 DUP1 
279 DUP4 
280 SUB 
281 DUP2 
282 DUP6 
283 GAS 
284 DELEGATECALL 
285 SWAP2 
286 POP 
287 POP 
Enter fullscreen mode Exit fullscreen mode

Os OPCODES acima representam as instruções de baixo nível executadas pela EVM quando a função set() foi chamada no contrato CallingContract. Aqui está uma breve explicação de cada opcode:

  • JUMP (opcode 146): Este opcode é usado para implementar o fluxo de controle no bytecode.
  • PUSH1 (opcode 148, 161, 181, 215 e 273) e PUSH2 (opcode 151, 156, 267 e 268): Esses opcodes empurram um valor constante para a pilha. Os valores constantes são usados para endereços de memória e deslocamentos no bytecode.
  • MLOAD (opcode 150 e 164) e MSTORE (opcode 173, 180, 213 e 221): Esses opcodes são usados para ler e escrever dados de e para a memória.
  • SWAP1, SWAP2, SWAP3 e DUP1 (opcodes 154, 155, 172, 206, 207, 208, 212, 217, 222, 223, 225, 228, 229, 231, 252, 253 e 280): Esses opcodes são usados para manipular a pilha.
  • ADD (opcodes 169, 211, 213, 223, 260 e 262) e SUB (opcodes 209, 251 e 262): Esses opcodes são usados para realizar operações aritméticas.
  • GAS (opcode 283): Este opcode empurra todo o gás disponível para a pilha. É usado para limitar a quantidade de gás disponível para a operação de delegatecall.
  • DELEGATECALL (opcode 284): Este opcode executa a operação delegatecall de fato, transferindo o controle para o contrato chamado enquanto preserva o armazenamento do contrato chamador e executa no contexto do contrato chamador.

Preço do Gás

O preço do gás para um opcode delegatecall é calculado da mesma forma que para qualquer outro opcode na Máquina Virtual Ethereum (EVM). É determinado pelo preço atual do gás na rede multiplicado pelo limite de gás para a transação na qual o opcode delegatecall está incluído.

O preço do gás para um opcode delegatecall é o custo do gás para executar o código no contrato de destino, além de qualquer gás adicional necessário para o próprio delegatecall. Este custo de gás é deduzido do limite de gás do contrato chamador, e qualquer gás restante é retornado ao contrato chamador após a conclusão do delegatecall.

Ao usar delegatecall, é importante notar que o custo do gás não é determinado apenas pelo código sendo executado no contrato de destino, mas também pelo estado do contrato chamador. Isso significa que, se o estado do contrato chamador for complexo, pode ser necessário mais gás para executar o opcode delegatecall.
Portanto, é importante gerenciar cuidadosamente os custos de gás ao usar delegatecall para evitar ficar sem gás durante a execução. Isso pode ser feito estimando o custo do gás do delegatecall com antecedência, usando código eficiente em termos de gás e layouts de armazenamento, e testando completamente o delegatecall em um ambiente de teste antes de implantá-lo na rede principal.

Conclusão

Delegatecall é um recurso do Solidity que permite que contratos executem código de outros contratos, mantendo o armazenamento e o estado do contrato chamador. Este artigo abordou como o delegatecall funciona, seus benefícios e um caso de uso real. Também fornecemos um guia passo a passo para usar o delegatecall e destacamos armadilhas comuns a serem evitadas, incluindo gerenciamento de layout de variáveis de estado, tratamento adequado de exceções e gerenciamento de custos de gás. Ao usar o delegatecall com cuidado, os desenvolvedores de Solidity podem aumentar a reutilização de código e criar contratos inteligentes mais modulares e flexíveis. Obrigado por ler!



Esse artigo é uma tradução feita por @bananlabs. Você pode encontrar o artigo original aqui

Latest comments (0)