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?
5 . Os fundamentos das chamadas de funções de contrato
6 . Como implementar o padrão delegante de proxy
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
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.
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())
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
)
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)
}
}
}
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)}
}
}
}
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óriaA 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();
}
}
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 comdelegatecall
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;
}
}
Implicações do uso desse padrão
Aumento da complexidade devido ao inline Assembly
A complexidade do padrão aumenta as chances de erros ou comportamentos inesperados
Alterações de armazenamento: os campos não podem ser reorganizados nem removidos
Perda potencial de confiança dos usuários. Com contratos atualizáveis, um dos principais benefícios das blockchains, que é a imutabilidade, é evitado.
Os usuários devem confiar nas entidades responsáveis para não introduzir nenhum comportamento indesejado em suas atualizações
É 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
Exemplos
O contrato atualizável verifica se o destino (endereço da versão do contrato ativo) está armazenado no mesmo slot da versão atual.
As validações podem ser implementadas para outros campos de armazenamento
Antes de implantar esses contratos na rede, é necessário testar todas as opções. Caso contrário, você pode acabar sem um contrato em funcionamento após a próxima atualização e seria impossível para você atualizá-lo.
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.
Latest comments (0)