WEB3DEV

Cover image for Padrão de Proxy e Contratos Inteligentes Atualizáveis
Panegali
Panegali

Posted on • Atualizado em

Padrão de Proxy e Contratos Inteligentes Atualizáveis

Quando os contratos inteligentes são implantados na blockchain Ethereum, eles são imutáveis ​​e, portanto, não podem ser atualizados. No entanto, o código pode ser rearquitetado em diferentes contratos, permitindo assim atualizações lógicas enquanto o armazenamento permanece o mesmo. Tendo dito isso, os usuários concordam que a lógica do token deve ser atualizável?

A imutabilidade vem com a desvantagem de que os bugs não serão corrigidos, as otimizações de gás não serão implementadas, a funcionalidade existente não será aprimorada... Abandonar essa propriedade da EVM também seria uma solução ruim, pois roubaria da Ethereum um de seus principais recursos.


Sumário

1 . Quando usá-lo

2 . Como funciona?

3 . O que significa que o armazenamento do proxy é usado para execução de funções?

4 . E quanto ao contexto?

5 . Os fundamentos das chamadas de funções de contrato

6 . Como implementar o padrão delegante de proxy

7 . O que é uma delegatecall

8 . Podemos não usar inline Assembly

9 . Como seria sem chamar delegatecall?

10 . Como implementar o delegado

11 . Call vs Delegatecall, Estado vs Lógica

12 . Inicialização do contrato do receptor

13 . Implicações do uso desse padrão

14 . Casos de uso

15 . Exemplos

16 . Conclusões


1

Quando utilizar

  • Adapte-se a um ambiente em mudança: corrija bugs, supere as limitações do contrato imutável

  • Atualizações virtuais (os contratos existentes ainda não podem ser alterados). Isso significa que, apesar dos contratos originais permanecerem inalterados, uma nova versão pode ser implantada e seu endereço substitui o antigo no armazenamento

  • Para evitar a quebra de dependências de outros contratos que fazem referência ao contrato atualizado

  • Os usuários podem não saber sobre o lançamento de uma nova versão do contrato (que vem com um novo endereço)

Como funciona

Primeiro, um chamador externo faz uma chamada de função para o proxy. Em segundo lugar, o proxy delega a chamada ao delegado, onde o código da função está localizado. Em terceiro lugar, o resultado é retornado ao proxy, que o encaminha ao chamador. Porque o delegatecall é usado para delegar a chamada, a função chamada é executada no contexto do proxy. Isso significa que o armazenamento do proxy é usado para execução da função, resultando na limitação de que o armazenamento do contrato delegado deve ser anexado apenas. O código de operação (opcode) delegatecall foi introduzido no EIP-7.

Dada a lógica do padrão, o proxy também é conhecido como um despachante que delega chamadas aos módulos específicos. Esses módulos são conhecidos como delegados (pois o trabalho é delegado a eles pelo contrato do proxy).

O que significa que o armazenamento do proxy é usado para execução de funções?

Sabemos que o resultado desse comportamento é a limitação de que o armazenamento do contrato delegado deve ser apenas anexado. Agora, no caso de uma atualização, as variáveis ​​de armazenamento existentes não podem ser alteradas ou omitidas. Em vez disso, apenas novas variáveis ​​podem ser adicionadas.

A razão para isso é que alterar a estrutura de armazenamento no delegado atrapalharia o armazenamento no proxy, que espera a estrutura anterior.

Aqui está um exemplo.

E quanto ao contexto?

O contexto de execução permanece o mesmo, pois o armazenamento do chamador é usado. Portanto, msg.sender e msg.value não mudam.

Dividir os contratos inteligentes em vários contratos inteligentes relacionados

Esta é uma abordagem comum usada em outras linguagens além do Solidity. Por exemplo, você pode ter um contrato para a venda de tokens, caso em que as regras para calcular o número de tokens que precisam ser enviados para a carteira de origem do Ether não estão claramente especificadas. Nesse caso, os cálculos dos valores são feitos em um contrato separado que pode ser atualizado posteriormente, se necessário.

Os fundamentos das chamadas de funções de contrato

Cada transação na Ethereum tem um campo data opcional que deve ser deixado vazio quando os ethers estão sendo transferidos. No entanto, ao interagir com um contrato deve conter algo chamado call data.

  • Identificador de função (primeiros 4 bytes da assinatura de função hash) por exemplo keccak256(“transfer(address, uint256)”)

  • Argumentos de função que vêm após o identificador de função e que são codificados de acordo com a especificação ABI

O compilador Solidity tem uma lógica de ramificação que analisa os dados da chamada e decide qual função chamar, dependendo do identificador da função extraído dos dados da chamada do formulário. Como o Solidity não nos permite tomar decisões nesse nível de profundidade, mostraremos mais adiante que seremos forçados a usar o Assembly para escrever alguma lógica.

Como implementar o padrão delegante de proxy

Essencialmente, a lógica de negócios é implementada nas funções que são chamadas do contrato de proxy. Como resultado desse encadeamento, torna-se possível trocar o contrato de implementação por outro. Isso ocorre porque o contrato de proxy conhece apenas o endereço do contrato que está implementando a lógica de negócios real.

Como o Solidity é uma abstração de alto nível, tudo o que ele pode nos fornecer é uma função de fallback. Esta é uma função especial que é chamada sempre que uma função não suportada por um contrato é chamada.

Em última análise, nosso objetivo com contratos atualizáveis ​​é obter 'dados de chamada' e passá-los para o contrato de implementação como estão, sem analisá-los ou modificá-los.

Primeiro, precisamos carregar os dados da chamada na memória

let ptr := mload(0x40)
calldatacopy(ptr, 0, calldatasize())
Enter fullscreen mode Exit fullscreen mode

A memória da EVM é tratada em slots (espaços). Cada slot tem um índice e ocupa 32 bytes. A função calldatasize() acima obtém o tamanho dos dados da chamada e copia os dados da chamada de um tamanho específico para um slot de memória localizado em um índice ptr, ocupando outros slots de memória se não couber. mload lê 32 bytes do índice especificado. O 0x40 é um slot especial que aponta para o índice do próximo slot de memória livre, para que possamos salvar os dados da chamada em um slot livre na memória. Em seguida, a função sload lerá o valor naquele endereço. Os 2 zeros a seguir são out e outsize respectivamente, e permitem definir onde na memória armazenar os dados de retorno

A maneira de retransmitir a chamada seria algo como isto:

let result := delegatecall(
gas(),
sload(implementation.slot),
ptr,
calldatasize(),
0,
0
)
Enter fullscreen mode Exit fullscreen mode

gas significa quanto gás resta na chamada do contrato atual e informa ao outro contrato quanto é permitido gastar. A variável de estado implementation é um recurso do Solidity que permite obter facilmente o endereço do slot de memória de uma variável de estado.

O que é uma delegatecall

Uma delegatecall é usada para executar funções em um delegado no contexto da estrutura proxy. Isso significa que msg.data é encaminhado (identificador de função nos primeiros 4 bytes). Depois de encaminhado, para ser executado e acionar o mecanismo de encaminhamento a cada chamada de função, ele é colocado na função fallback do contrato de proxy. No entanto, a delegatecall retorna apenas um Booleano, informando se a execução foi bem-sucedida ou não.

Para resolver essa limitação, é usado o inline Assembly. Isso permite um controle mais granular sobre a pilha com uma linguagem semelhante à usada pela EVM. Usando o inline assembly, podemos dissecar o valor de retorno da delegatecall e retornar o resultado para o chamador.

Podemos não usar inline Assembly

Isso pode ser evitado retornando o resultado ao chamador por meio de eventos. Como os eventos não podem ser ouvidos ou de dentro dos contratos, usaríamos uma fachada e agiríamos de acordo com o resultado daí em diante.

Como seria sem chamar delegatecall?

Quando queremos que o contrato de proxy retorne o que foi retornado do chamado e não sabemos o tipo de dados de retorno com antecedência, podemos usar um trecho de código como o abaixo:

fallback() external {
assembly {
let ptr := mload(0x40)
calldatacopy(ptr, 0, calldatasize())

let result := delegatecall(
gas(),
sload(implementation.slot),
ptr,
calldatasize(),
0,
0
)

let size := returndatasize()

returndatacopy(ptr, 0, size)

switch result
  case 0 {
  revert(ptr, size)
}
default {
  return(ptr, size)
}
}
}
Enter fullscreen mode Exit fullscreen mode

Como implementar o delegado

O delegado pode ser implementado como qualquer outro contrato regular. Ele não precisa saber sobre o proxy usando seu código. A única coisa a saber é que, ao atualizar o contrato, a sequência de armazenamento tem de ser a mesma. Lembre-se de que apenas adições são permitidas.

Portanto, o mecanismo de atualização, armazenando a versão atual do delegado, pode ocorrer no armazenamento externo ou no próprio proxy.

  • Se o endereço estiver armazenado no proxy, uma função protegida precisa ser implementada. Isso permite que um endereço autorizado atualize o endereço do delegado.

Este seria um exemplo que armazena a versão atual do delegado em seu próprio armazenamento

contract Proxy{
address delegate; // armazenar o endereço do delegado
address owner = msg.sender // armazenar o endereço do proprietário
/// @notice esta função permite que uma nova versão do delegado seja utilizada sem que o chamador tenha que se preocupar com isso
function upgradeDelegate(address _newDelegateAddress) public {
  require(msg.sender == owner);
  delegate = _newDelegateAddress;
}
function () external payable {
 assembly {
 let _target := sload(0)
 calldatacopy(0x0, 0x0, calldatasize)
 let result := delegatecall(gas, _target, 0x0, calldatasize, 0x0, 0)
 returndatacopy(0x0, 0x0, returndatasize)
 switch result case 0 {revert(0,0)} default {return (0,   returndatasize)}
}
}
}
Enter fullscreen mode Exit fullscreen mode

O mecanismo de encaminhamento entra em vigor na segunda função, que é a função de fallback sendo chamada para cada identificador de função desconhecida. Consequentemente, toda chamada de função para o proxy acionará essa função e executará o código Assembly.

  • A linha 14 carrega a primeira variável no armazenamento, que é o endereço do delegado, e a armazena na variável de memória _target

  • A linha 15 copia a assinatura da função e quaisquer parâmetros na memória

  • A linha 16 faz a delegatecall para o Endereço _target, incluindo os dados da função que foram armazenados na memória

  • A linha 17 copia o valor de retorno na memória

  • A instrução switch verifica o resultado Booleano da execução.

— Se o resultado for positivo, o resultado é retornado ao chamador da função

— Caso contrário, qualquer mudança de estado é revertida

contract Delegate {
uint public n = 1;
function add() public {
n = 5;
}
}

contract Caller {
Delegate proxy;
function caller(address _proxyAddress) public {
proxy = Delegate(_proxyAddress);
}
function go() public {
proxy.adds();
}
}
Enter fullscreen mode Exit fullscreen mode

Call vs Delegatecall e Estado vs Lógica

O estado do contrato inteligente é persistente e é armazenado na blockchain. Este estado é acessível por meio de variáveis ​​de estado. Ambas call e delegatecall são usadas ​​para chamar outro contrato. No entanto, elas diferem em como lidam com o estado do contrato do chamador

  • Ao usar call, o chamador e o chamado têm seus próprios estados separados (isso é esperado por padrão)

  • Ao usar delegatecall, o chamado usa o estado do chamador, o que significa que o contrato que você está chamando com delegatecall usa o estado do contrato do chamador.

Inicialização do contrato do receptor

É importante mencionar que o construtor do contrato chamado não pode ser usado para inicialização quando usado por meio de um proxy. Quando usamos o construtor para inicializar o estado, queremos que ele seja inicializado dentro do estado do contrato proxy. Uma solução alternativa seria semelhante a:

contract BusinessLogic {
bool initialized;
uint 256 someNumber;
function init() public {
require(!initialized, "already initialized");
someNumber = 0x42;
initialized = true;
}
}
Enter fullscreen mode Exit fullscreen mode

Implicações do uso desse padrão

  1. Aumento da complexidade devido ao inline Assembly

  2. A complexidade do padrão aumenta as chances de erros ou comportamentos inesperados

  3. Alterações de armazenamento: os campos não podem ser reorganizados nem removidos

  4. Perda potencial de confiança dos usuários. Com contratos atualizáveis, um dos principais benefícios das blockchains, que é a imutabilidade, é evitado.

  5. Os usuários devem confiar nas entidades responsáveis ​​para não introduzir nenhum comportamento indesejado em suas atualizações

  6. É necessário delimitar cuidadosamente o acesso a uma função que altera o endereço do contrato ativo

Casos de uso

  • Big Dapps contendo um grande número de contratos. Um exemplo poderia ser os mercados de previsão, onde os usuários apostam no resultado de eventos futuros. Nesse caso, o endereço do contrato atualizável não é armazenado no próprio proxy, mas em algum tipo de resolvedor de endereços.

  • Resolver bugs encontrados no contrato

  • Resolver erros que podem levar à perda de fundos no contrato

2

Exemplos

Conclusões

Para criar contratos inteligentes atualizáveis, o padrão proxy parece ser uma estratégia completa. Ele permite que os desenvolvedores separem o mecanismo atualizável do projeto do contrato. Isso simplifica o projeto lógico e será menos propenso a erros. Nenhuma estratégia é perfeita e fazer a escolha certa dependerá dos casos de uso. Todas as estratégias e padrões de design são complexas por conta própria e os desenvolvedores de aplicativos sempre devem evitar vulnerabilidades de segurança.


Artigo escrito por Alvaro Serrano Rivas e traduzido por Marcelo Panegali.

Top comments (0)