O Solidity, a linguagem de programação popular para contratos inteligentes Ethereum, é poderoso e de fácil utilização. No entanto, às vezes é necessário ir além da superfície e aproveitar o poder bruto da EVM (Máquina Virtual Ethereum). É aí que entra o Assembly. Assembly é a linguagem de baixo nível que permite aos desenvolvedores mergulhar profundamente no funcionamento interno da Máquina Virtual Ethereum e ajustar seus contratos inteligentes para obter máxima eficiência e desempenho. É como ter um superpoder que permite otimizar cada linha de código e extrair todo o potencial de seus contratos inteligentes.
Mas antes de começarmos a escrever algum código Assembly, precisamos entender como a EVM funciona.
EVM e Opcodes
A Máquina Virtual Ethereum é como o coração pulsante da blockchain Ethereum. É um computador poderoso e descentralizado que executa contratos inteligentes, garantindo consistência e confiabilidade em toda a rede.
Mas como ela funciona?
Ao compilar um contrato, você obterá um bytecode, que é uma longa sequência de bytes, como por exemplo:
608060405234801561001....36f6c63430008070033ab
Esse bytecode representa uma lista de instruções pequenas, cada uma composta por 1 byte, conhecidas como opcodes
(código de operação).
Os opcodes são usados para realizar várias operações, incluindo cálculos aritméticos, manipulação de memória, fluxo de controle e acesso a armazenamento.
No bytecode fornecido, por exemplo, a primeira instrução é 60 (1 byte), que corresponde ao opcode PUSH1
. No momento em que este artigo está sendo escrito, existem 141 opcodes disponíveis na Máquina Virtual Ethereum. Você pode verificar todos os opcodes da EVM em evm.codes.
O que é Assembly?
Assembly, também conhecido como "inline Assembly
", é uma linguagem de baixo nível que nos permite acessar a Máquina Virtual Ethereum em um baixo nível.
É como ter um passe de bastidores para o funcionamento interno da EVM!
Com o Assembly, podemos escrever código que contorna algumas das características de segurança e verificações do Solidity, dando-nos mais controle sobre nossos contratos inteligentes.
Quando escrevemos Assembly em Solidity, usamos uma linguagem chamada Yul. Yul é uma linguagem intermediária que pode ser compilada para bytecode para a EVM.
A qualquer momento, ao codificar em Solidity, podemos usar a palavra-chave assembly { }
para começar a escrever inline Assembly.
Agora, vamos falar sobre a ordem dos níveis de controle. Começamos com o Solidity, que oferece uma abordagem de alto nível para escrever contratos inteligentes. Mas se quisermos ainda mais controle, podemos mergulhar no Yul (Assembly). Yul nos permite manipular a EVM em um nível ainda mais baixo, nos dando a capacidade de ajustar nosso código e torná-lo mais eficiente.
Solidity < Yul (Assembly) < bytecode (opcodes)
E se estivermos nos sentindo aventureiros, podemos ir além do Yul e escrever bytecode bruto para a EVM. Isso é como falar a linguagem da EVM diretamente e não requer um compilador. É como ser um mestre da própria EVM!
Escrevendo Inline Solidity
Digamos que tenhamos um contrato simples chamado Box
. Este contrato permite armazenar um valor, alterá-lo e recuperá-lo. Aqui está uma análise do código:
pragma solidity ^0.8.14;
contract Box {
uint256 private _value;
event NewValue(uint256 newValue);
function store(uint256 newValue) public {
_value = newValue;
emit NewValue(newValue);
}
function retrieve() public view returns (uint256) {
return _value;
}
}
Agora, vamos converter o código Solidity fornecido em inline Assembly. Começaremos com a função de recuperação (retrieve). No código Solidity original, a função retrieve lê o valor armazenado em _value
no armazenamento do contrato e o retorna. Para obter um resultado semelhante em Assembly, podemos usar o opcode sload
para ler o valor.
O opcode sload
recebe um único parâmetro, que é a chave do slot de armazenamento. Neste caso, a variável _value
está armazenada no slot #0. Portanto, em Assembly, podemos escrever:
assembly {
let v := sload(0) // Ler do slot nº 0
}
Agora que temos o valor, precisamos retorná-lo. Em Assembly, podemos usar o opcode return
para realizar isso. O opcode return
recebe dois parâmetros: o deslocamento offset
, que é a localização na memória onde o valor começa, e o tamanho size
, que é o número de bytes a serem retornados.
No entanto, o valor v
retornado pelo sload
está na pilha de chamadas (call stack), não na memória. Portanto, precisamos movê-lo para a memória primeiro. Para isso, podemos usar o opcode mstore
, que armazena um valor na memória. Ele recebe dois parâmetros:
-
offset
, que é a localização (no array de memória) onde o valor deve ser armazenado. -
value
, que são os bytes a serem armazenados (que év
para nós).
Agora, juntando tudo, o código Assembly fica:
assembly {
let v := sload(0) // Ler do slot nº 0
mstore(0x80, v) // Armazene v na posição 0x80 na memória
return(0x80, 32) // Retorna 32 bytes (uint256)
}
É isso! Nós convertemos com sucesso o corpo da função retrieve em código Assembly.
Nota: você pode estar se perguntando por que escolhemos especificamente a posição 0x80 na memória para armazenar o valor. Isso ocorre porque o Solidity reserva os quatro primeiros slots de 32 bytes (de 0x00 a 0x7f) para propósitos especiais. Portanto, a memória livre começa a partir de 0x80. No nosso caso simples, está tudo bem usar 0x80 para armazenar a nova variável. No entanto, para operações mais complexas, você precisaria controlar o ponteiro para a memória livre e gerenciá-lo adequadamente.
function retrieve() public view returns (uint256) {
assembly {
let v := sload(0)
mstore(0x80, v)
return(0x80, 32)
}
}
Agora, passamos para a função store
para armazenar uma variável. Podemos usar o opcode sstore
para isso. Ele recebe dois parâmetros:
-
key
, uma chave de 32 bytes no armazenamento. -
value
, o valor a ser armazenado.
Portanto, em Assembly, podemos escrever:
assembly {
sstore(0, newValue) // armazenar valor no slot 0 do armazenamento
}
Agora que armazenamos o valor, podemos prosseguir para emitir um evento usando o opcode log1
. O opcode log1 requer três parâmetros:
-
offset
: isso representa o deslocamento em bytes na memória onde os dados do evento serão armazenados. -
size
: isso especifica o tamanho em bytes dos dados a serem copiados. -
topic
: este é um valor de 32 bytes que serve como rótulo ou identificador para o evento.
Para garantir que o opcode log1
tenha o deslocamento necessário na memória, precisamos usar o opcodemstore
para armazenar o valor na memória. Aqui está o código atualizado:
assembly {
sstore(0, newValue) // armazenar valor no slot 0 do armazenamento
mstore(0x80, newValue) // armazenar newValue em 0x80
}
Para usar o opcode log1, precisamos fornecer três parâmetros. O primeiro parâmetro, offset
, deve ser definido como 0x80
, uma vez que armazenamos o valor usando o opcode mstore
. O segundo parâmetro, size
, pode ser definido como 0x20
, o que representa 32 bytes. Agora, você pode estar se perguntando o que é aquele terceiro argumento que passamos para o log1
. É o topic
— uma espécie de rótulo para o evento, como o nome — NewValue
. O argumento passado é nada mais do que o hash da assinatura do evento:
bytes32(keccak256("NewValue(uint256)"))
// 0xac3e966f295f2d5312f973dc6d42f30a6dc1c1f76ab8ee91cc8ca5dad1fa60fd
Com essas atualizações, nossa função "store" fica assim:
function store(uint256 newValue) public {
assembly {
// armazenar valor no slot 0 do armazenamento
sstore(0, newValue)
// emitir evento
mstore(0x80, newValue)
log1(0x80, 0x20, 0xac3e966f295f2d5312f973dc6d42f30a6dc1c1f76ab8ee91cc8ca5dad1fa60fd)
}
}
Finalmente, nosso contrato Box
fica assim agora:
pragma solidity ^0.8.14;
contract Box {
uint256 public value;
function retrieve() public view returns(uint256) {
assembly {
let v := sload(0)
mstore(0x80, v)
return(0x80, 32)
}
}
function store(uint256 newValue) public {
assembly {
sstore(0, newValue)
mstore(0x80, newValue)
log1(0x80, 0x20, 0xac3e966f295f2d5312f973dc6d42f30a6dc1c1f76ab8ee91cc8ca5dad1fa60fd)
}
}
}
Podemos criar outro contrato para enviar ether para um endereço. Veja como isso pode ser feito:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.14;
contract MyContract {
address public owner = payable(0x5B38Da6a701c568545dCfcB03FcB875f56beddC4);
function sendETH(uint256 _amount) public payable {
require(msg.value >= _amount,"Not Enough ETH Sent");
bool success;
assembly {
let o := sload(0)
success := call(gas(), o, _amount, 0, 0, 0, 0)
}
require(success, "Failed to send ETH");
}
}
Vamos analisar o código Assembly passo a passo:
- Primeiro, o endereço do proprietário é armazenado no slot de armazenamento 0 e atribuído à variável local
o
. O opcodesload
é usado para ler um valor do armazenamento. - A próxima linha executa o opcode
call
, que é usado para enviar ether para um endereço. O opcodecall
recebe vários argumentos: -
gas
: a funçãogas()
retorna o gás restante para o contexto de execução atual. Neste caso, é passado como o primeiro argumento a ser chamado, indicando que a quantidade máxima de gás deve ser fornecida para a chamada da função. -
address
: o endereço do contrato/usuário para chamar. É o valor carregado do slot de armazenamento 0. -
value
: a quantidade de Ether (emwei
) a ser enviada junto à chamada da função. Neste caso, é passada como o segundo argumento para chamar. - Os próximos quatro argumentos (
0
,0
,0
,0
) são usados para passar dados adicionais para a função que está sendo chamada. Neste trecho de código, eles são definidos como zero, indicando que nenhum dado adicional está sendo passado. - O resultado do opcode de chamada é atribuído à variável local success. Ela será verdadeira se a chamada da função for bem sucedida e falsa caso contrário.
Limitações
Quando se trata de contratos inteligentes em Solidity, há um compromisso a ser feito entre legibilidade e eficiência. Enquanto muitos desenvolvedores de front-end podem compreender facilmente as funções executadas em um contrato inteligente Solidity e incorporá-las em suas consultas Web3, o código Assembly pode ser intimidante para aqueles que não estão familiarizados com programação de baixo nível.
O código Assembly em Solidity pode parecer assustador e difícil de compreender devido à sua natureza de baixo nível. A lógica e o fluxo do código podem não ser imediatamente óbvios para aqueles que não estão acostumados a trabalhar com Assembly. No entanto, apesar de sua complexidade inicial, o uso de Assembly em Solidity pode oferecer benefícios significativos, como eficiência de gás aprimorada e vantagem competitiva.
Um exemplo do uso de Assembly em Solidity é a capacidade de retornar um nome de contrato. Embora o código possa parecer inicialmente complexo, com uma variável nomeada representada como um código hex ilegível retornado por meio de Assembly.
function _name() internal pure override returns (string memory) {
// Retorna o nome do contrato.
assembly {
mstore(0x20, 0x20)
mstore(0x47, 0x07536561706f7274)
return(0x20, 0x60)
}
}
Esta função é utilizada pelo contrato OpenSea Seaport.
Pode ser bastante assustador no início, mas ao aproveitar o Assembly em todo o código-fonte, os desenvolvedores podem otimizar seus contratos inteligentes para consumir menos gás, resultando em economia de custos para os usuários. Essa eficiência de gás pode proporcionar uma vantagem competitiva, especialmente em plataformas como a OpenSea, onde os custos de transação podem impactar significativamente a experiência do usuário e a adoção.
Pronto para construir?
Em conclusão, o uso de Assembly em Solidity pode ser tanto um fardo quanto uma vantagem competitiva, dependendo do contexto e da experiência da equipe de desenvolvimento. Embora o código Assembly possa parecer inicialmente pouco atraente e difícil de entender, ele pode oferecer benefícios significativos em termos de eficiência de gás e economia de custos.
No entanto, é crucial para os desenvolvedores considerarem cuidadosamente os compromissos e avaliarem se a complexidade do código Assembly vale os ganhos potenciais em seu caso de uso específico.
Artigo escrito por Vedant Chainani. Traduzido por Marcelo Panegali.
Oldest comments (0)