O que é Yul?
Yul é uma linguagem de programação intermediária que pode ser usada para escrever uma forma da linguagem assembly dentro de contratos inteligentes. Embora muitas vezes vemos Yul sendo usado dentro de contratos inteligentes, você pode escrever um contrato inteligente inteiro em Yul. A compreensão de Yul pode elevar o nível dos seus contratos inteligentes e permitir que você compreenda o que está acontecendo por trás dos panos na solidity, o que por sua vez pode ajudá-lo a economizar os custos de gas do usuário. Podemos identificar Yul em um contrato inteligente com a seguinte sintaxe.
assembly {
// faça algo
}
No restante deste artigo discutiremos os fundamentos do uso de Yul por meio de exemplos. Recomendo que sigam no remix.
Atribuições de variáveis, Operações e Avaliações
O primeiro tópico que precisamos abordar é o de operações simples. Yul tem +
, -
, *
, /
, %
, **
, <
, >
, e =
. Observe que >=
e <=
não estão incluídos; o Yul não possui essas operações. Além disso, em vez de avaliações iguais a verdadeiro ou falso, elas equivalem a 1 ou 0, respectivamente. Com isso, vamos começar com o aprendizado de algum Yul!
Vamos dar uma olhada rápida em um exemplo antes de seguir em frente.
function addOneAnTwo() external pure returns(uint256) {
// Podemos acessar variáveis do Solidity dentro de nosso código Yul
uint256 ans;
assembly {
// atribui variáveis em Yul
let one := 1
let two := 2
// adiciona duas variáveis juntas
ans := add(one, two)
}
return ans;
}
Declarações de Loops for & If
Para aprender sobre ambas, vamos escrever uma função que conta quantos números são pares em uma série.
function howManyEvens(uint256 startNum, uint256 endNum) external pure returns(uint256) {
// o valor que retornaremos
uint256 ans;
assembly {
// sintaxe para o loop for
for { let i := startNum } lt( i, add(endNum, 1) ) { i := add(i,1) }
{
// se i == 0, pule a iteração
if iszero(i) {
continue
}
// verifica se i % 2 == 0
// poderíamos ter usado iszero, mas eu quis te mostrar a eq()
if eq( mod( i, 2 ), 0 ) {
ans := add(ans, 1)
}
}
}
return ans;
}
A sintaxe para declarações if
é bem semelhante à do solidity, entretanto, não precisamos colocar a condição entre parênteses. Para o loop for
, observe que estamos usando colchetes quando declaramos i
e incrementamos i
, mas não quando avaliamos a condição. Além disso, usamos uma instrução continue
para pular uma iteração do loop. Também podemos usar declarações break
no Yul.
Armazenamento
Antes de nos aprofundarmos em como o Yul funciona, precisamos de um bom entendimento de como funciona o armazenamento em contratos inteligentes. O armazenamento é composto de uma série de slots. Há 2²⁵⁶ slots para um contrato inteligente. Ao declarar as variáveis, começamos com o slot 0 e incrementamos a partir daí. Cada slot tem 256 bits de comprimento (32 bytes) e é daí que foram criados os nomes uint256
e bytes32
. Todas as variáveis são convertidas a hexadecimais. Se uma variável como a uint128
for usada, não usamos um slot inteiro para armazenar esta variável. Ao invés disso, ela é preenchida com 0's no lado esquerdo. Vejamos um exemplo para entender melhor.
// slot 0
uint256 var1 = 256;
// slot 1
address var2 = 0x9ACc1d6Aa9b846083E8a497A661853aaE07F0F00;
// slot 2
bytes32 var3 = 0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff;
// slot 3
uint128 var4 = 1;
uint128 var5 = 2;
var1
: Como as variáveis uint256
são iguais a 32 bytes, a variável var1
ocupa a totalidade do slot 0. Aqui está o que está sendo armazenado no slot 0: 0x0000000000000000000000000000000000000000000000000000000000000100
.
var2
: Os endereços são um pouco mais complexos. Como eles absorvem apenas 20 bytes de armazenamento, os endereços são preenchidos com 0s no lado esquerdo. Aqui está o que está sendo armazenado no slot 1: 0x0000000000000000000000009acc1d6aa9b846083e8a497a661853aae07f0f00
.
var3
: Esta pode parecer simples, o slot 2 é consumido pela totalidade da variável de bytes32
.
var4
& var5
: Lembra-se de quando eu mencionei que a uint128
foi preenchida com 0’s? Bem, se requisitarmos nossas variáveis para que a soma de seu armazenamento seja inferior a 32 bytes, podemos encaixá-las juntas em um slot! Isto é chamado de empacotamento de variáveis e você pode economizar gas com isso. Vejamos o que está armazenado no slot 3: 0x0000000000000000000000000000000200000000000000000000000000000001
. Observe que 0x000000000000000000000000000002
e 0x000000000000000000000000000001
cabem perfeitamente juntas no mesmo slot. Isso porque ambas ocupam 16 bytes (metade da capacidade de um slot).
Agora é hora de aprender um pouco mais sobre Yul!
Vamos ver outro exemplo!
function readAndWriteToStorage() external returns (uint256, uint256, uint256) {
uint256 x;
uint256 y;
uint256 z;
assembly {
// obtém o slot da var5
let slot := var5.slot
// obtém o offset da var5
let offset := var5.offset
// atribui x e y do solidity ao slot e ao offset
x := slot
y := offset
// armazena o valor 1 no slot 0
sstore(0,1)
// atribui z ao valor do slot 0
z := sload(0)
}
return (x, y, z);
}
x
= 3. Isto faz sentido, já que sabemos que a var5 está empacotada no slot 3. \
y
= 16. Isto também deve fazer sentido, pois sabemos que a var4
ocupa metade do slot 3. Como as variáveis são empacotadas da direita para a esquerda, temos o byte 16 como índice inicial da var5
. \
z
= 1. O sstore()
está atribuindo ao slot 0 o valor 1. Em seguida, atribuímos z ao valor do slot 0 com o valor sload()
.
Antes de continuarmos, você deve adicionar esta função ao seu arquivo remix. Ela o ajudará a ver o que está sendo armazenado em cada slot de armazenamento.
// input é o slot de armazenamento que queremos ler
function getValInHex(uint256 y) external view returns (bytes32) {
// como Yul funciona com hex queremos o retorno em bytes
bytes32 x;
assembly {
// atribui valor do slot y para o x
x := sload(y)
}
return x;
}
Agora vamos ver algumas estruturas de dados mais complexas!
// slot 4 & 5
uint128[4] var6 = [0,1,2,3];
Ao trabalhar com arrays estáticos, a EVM sabe quantos espaços devem ser alocados para nossos dados. Com este array em particular, estamos empacotando 2 elementos por slot. Portanto, se você chamar getValInHex(4),
será retornado 0x0000000000000000000000000000000100000000000000000000000000000000
. Como era de se esperar, lendo da direita para a esquerda, vemos o valor 0 e o valor 1. O slot 5 contém 0x0000000000000000000000000000000300000000000000000000000000000002
.
A seguir, vamos ver os arrays dinâmicos.
// slot 6
uint256[] var7;
Tente chamar a função getValInHex(6)
. Você verá que ela retorna 0x00
. Como a EVM não sabe quantos slots de armazenamento precisam ser alocados, não podemos armazenar o array aqui. Ao invés disso, o hash keccak256 do slot de armazenamento atual (slot 6) é usado como índice inicial do array. A partir daqui, tudo que precisamos fazer é adicionar o índice do elemento desejado para recuperar o valor.
Aqui está um exemplo de código demonstrando como encontrar um elemento de um array dinâmico.
function getValFromDynamicArray(uint256 targetIndex) external view returns (uint256) {
// obtém o slot do array dinâmico
uint256 slot;
assembly {
slot := var7.slot
}
// obtém o hash de slot para o índice inicial
bytes32 startIndex = keccak256(abi.encode(slot));
uint256 ans;
assembly {
// adiciona o índice inicial e o índice de destino para obter o local de armazenamento. Em seguida, carrega o slot de armazenamento correspondente
ans := sload( add(startIndex, targetIndex) )
}
return ans;
}
Aqui recuperamos o slot do array, depois executamos uma operação add()
junto com uma sload()
para obter o valor do elemento do nosso array desejado.
Você pode estar se perguntando o que nos impede de ter uma colisão com o slot de outra variável? Isto é inteiramente possível, porém, extremamente improvável, uma vez que 2²⁵⁶ é um número muito grande.
Os mapeamentos se comportam de forma semelhante aos arrays dinâmicos, exceto que criamos o hash do slot junto com a chave.
// slot 7
mapping(uint256 => uint256) var8;
Para esta demonstração eu defini o valor do mapeamento var8[1] = 2
. Agora vamos ver um exemplo de como obter o valor de uma chave para um mapeamento.
function getMappedValue(uint256 key) external view returns(uint256) {
// obtém o slot do mapeamento
uint256 slot;
assembly {
slot := var8.slot
}
// cria o hash da chave e do valor do slot uint256
bytes32 location = keccak256(abi.encode(key, slot));
uint256 ans;
// Carrega o slot de armazenamento do local e devolve ans
assembly {
ans := sload(location)
}
return ans;
}
Como você pode ver, o código parece muito semelhante a quando encontramos um elemento de um array dinâmico. A principal diferença é que temos a chave e o slot juntos.
A parte final de nossa seção sobre armazenamento é conhecer sobre os mapeamentos aninhados. Antes de continuar lendo, encorajo você a escrever sua própria implementação de como ler um valor de mapa aninhado, com base no que você aprendeu até agora.
// slot 8
mapping(uint256 => mapping(uint256 => uint256)) var9;
Para este exemplo, eu defino o valor do mapeamento var9[0][1] = 2
. Aqui está o código. Vamos mergulhar!
function getMappedValue(uint256 key1, uint256 key2) external view returns(uint256) {
// obtém o slot do mapeamento
uint256 slot;
assembly {
slot := var9.slot
}
// cria o hash da chave e do valor do slot uint256
bytes32 locationOfParentValue = keccak256(abi.encode(key1, slot));
// cria o hash para a chave-pai e a chave aninhada
bytes32 locationOfNestedValue = keccak256(abi.encode(key2, locationOfParentValue));
uint256 ans;
// Carrega o slot de armazenamento do local e retornar ans
assembly {
ans := sload(locationOfNestedValue)
}
return ans;
}
Primeiro obtemos o hash da primeira chave (0). Depois pegamos o hash da primeira com a segunda chave (1). Finalmente, carregamos o slot do armazenamento para obter nosso valor.
Parabéns, você completou a seção sobre armazenamento com Yul!
Lendo & Escrevendo Variáveis Empacotadas
Suponha que você queira mudar a var5
para 4. Sabemos que a var5
está localizada no slot 3, então você deveria tentar algo assim:
function writeVar5(uint256 newVal) external {
assembly {
sstore(3, newVal)
}
}
Usando getValInHex(3)
vemos que o slot 3 foi reescrito para 0x0000000000000000000000000000000000000000000000000000000000000004
. Isso é um problema porque agora a var4
foi reescrita para 0. Nesta seção vamos examinar como ler e escrever variáveis empacotadas, mas primeiro precisamos aprender um pouco mais sobre a sintaxe Yul.
Se você não está familiarizado com estas operações, não se preocupe, estamos prestes a repassá-las com exemplos.
Vamos começar com and()
. Vamos pegar dois bytes32
e tentar o operador and()
e ver o que retornará.
function getAnd() external pure returns (bytes32) {
bytes32 randVar = 0x0000000000000000000000009acc1d6aa9b846083e8a497a661853aae07f0f00;
bytes32 mask = 0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff;
bytes32 ans;
assembly {
ans := and(mask, randVar)
}
return ans;
}
Se olharmos o output, veremos 0x0000000000000000000000009acc1d6aa9b846083e8a497a661853aae07f0f00
. A razão para isso é que a função de and()
é olhar para cada bit de ambos os inputs e comparar seus valores. Se ambos os bits são um 1 (pense nisso em termos de binário: ativo ou inativo), então nós mantemos o bit como está. Caso contrário, ele é ajustado para 0.
Agora olhe para o código do or()
.
function getOr() external pure returns (bytes32) {
bytes32 randVar = 0x000000000000000000000000ffffffffffffffffffffffffffffffffffffffff;
bytes32 mask = 0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff;
bytes32 ans;
assembly {
ans := or(mask, randVar)
}
return ans;
}
Dessa vez o output é 0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff
. Isto porque ele olha para ver se um ou outro bit está ativo. Vamos ver o que acontece se mudarmos a variável mask (máscara) para 0x00ffffffffffffffffffffff0000000000000000000000000000000000000000
. Como você pode ver, o output muda para 0x00ffffffffffffffffffffff9acc1d6aa9b846083e8a497a661853aae07f0f00
. Observe que o primeiro byte é 0x00
, porque nenhum dos inputs tem bits ativos para o primeiro byte.
xor()
é um pouco diferente. Ele necessita que um bit esteja ativo (1) e o outro esteja inativo (0). Aqui está uma demonstração de código.
function getXor() external pure returns (bytes32) {
bytes32 randVar = 0x00000000000000000000000000000000000000000000000000000000000000ff;
bytes32 mask = 0xffffffffffffffffffffffff00000000000000000000000000000000000000ff;
bytes32 ans;
assembly {
ans := xor(mask, randVar)
}
return ans;
}
O output é 0xffffffffffffffffffffffff0000000000000000000000000000000000000000
. A diferença principal é aparente quando vemos que os únicos bits ativos no output ocorrem quando 0x00
e 0xff
estão alinhados.
shl()
e shr()
funcionam de forma muito semelhante entre si. Ambos trocam o valor de input por uma quantidade de bits de input. shl()
muda para a esquerda e shr()
muda para a direita. Vamos dar uma olhada em um pouco de código!
function shlAndShr() external pure returns(bytes32, bytes32) {
bytes32 randVar = 0xffff00000000000000000000000000000000000000000000000000000000ffff;
bytes32 ans1;
bytes32 ans2;
assembly {
ans1 := shr(16, randVar)
ans2 := shl(16, randVar)
}
return (ans1, ans2);
}
Output: \
ans1
: 0x0000ffff00000000000000000000000000000000000000000000000000000000 \
:
ans20x00000000000000000000000000000000000000000000000000000000ffff0000
Vamos começar examinando o ans1
. Nós executamos shr()
de 16 bits (2 bytes). Como você pode ver, os dois últimos bytes mudam de 0xffff
para 0x0000
e os dois primeiros bytes são deslocados dois bytes para a direita. Sabendo disso, ans2
parece autoexplicativo; tudo o que acontece é que os bits são deslocados para a esquerda em vez de para a direita.
Antes de escrevermos para a var5
, vamos escrever uma função que lê a var4
e a var5
primeiro.
function readVar4AndVar5() external view returns (uint128, uint128) {
uint128 readVar4;
uint128 readVar5;
bytes32 mask = 0x00000000000000000000000000000000ffffffffffffffffffffffffffffffff;
assembly {
let slot3 := sload(3)
// a operação and() define a var5 para 0x00
readVar4 := and(slot3, mask)
// deslocamos a var5 para a posição da var4
// a antiga posição da var5 fica 0x00
readVar5 := shr( mul( var5.offset, 8 ), slot3 )
}
return (readVar4, readVar5);
}
O output é 1 & 2 como esperado. Para recuperar a var4
basta usar uma mask (máscara) para definir o valor para 0x0000000000000000000000000000000000000000000000000000000000000001
. Então, retornamos uma uint128
definida como 1. Ao ler a var5
, precisamos desviar a var4
deslocando-a para a direita. Isso nos deixa com 0x0000000000000000000000000000000000000000000000000000000000000002
, que podemos retornar. É importante notar que às vezes você terá que deslocar e mascarar em uníssono para ler um valor que tem mais de 2 variáveis empacotadas em um slot de armazenamento.
Ok, finalmente estamos prontos para mudar o valor da
var5
para 4!
function writeVar5(uint256 newVal) external {
assembly {
// carregar slot 3
let slot3 := sload(3)
// mascarar para limpar a var5
let mask := 0x00000000000000000000000000000000ffffffffffffffffffffffffffffffff
// isolar a var4
let clearedVar5 := and(slot3, mask)
// formatar novo valor na posição da var5
let shiftedVal := shl( mul( var5.offset, 8 ), newVal )
// combinar novo valor com a var4 isolada
let newSlot3 := or(shiftedVal, clearedVar5)
// armazenar novo valor para o slot 3
sstore(3, newSlot3)
}
}
O primeiro passo é carregar o slot de armazenamento 3. Em seguida, é preciso criar uma máscara. Da mesma forma que quando lemos a var4
, queremos isolar a variável para 0x0000000000000000000000000000000000000000000000000000000000000001
. O próximo passo é formatar nosso novo valor para estar na posição do slot da var5
de forma que fique assim 0x0000000000000000000000000000000400000000000000000000000000000000
. Diferentemente de quando lemos a var5
, desta vez, vamos mudar nosso valor para a esquerda. Finalmente, vamos usar or()
para combinar nossos valores em 32 bytes de hexadecimal e armazenar esse valor no slot 3. Podemos verificar nosso trabalho chamando getValInHex(3)
. Isso retornará 0x0000000000000000000000000000000400000000000000000000000000000001
, que é o que esperamos ver.
Ótimo, agora você sabe como ler e escrever para slots de armazenamento empacotados!
Memória
Muito bem, finalmente estamos prontos para aprender sobre memória!
A memória se comporta de forma diferente do armazenamento. A memória não é persistente. O que significa que uma vez terminada a função, a execução de todas as variáveis é apagada. A memória é comparável à heap (é o local de memória adequado para alocar muitos objetos grandes) em outras linguagens, mas não há um coletor de lixo. A memória é muito mais barata do que o armazenamento. As primeiras 22 palavras de custos de memória são calculadas linearmente, mas tenha cuidado porque depois disso os custos de memória se tornam quadráticos. A memória é disposta em 32 sequências de bytes. Mais tarde, teremos uma melhor compreensão disto, mas por enquanto entendemos 0x00
- 0x20
é uma sequência (você pode pensar nisso como um slot se isso ajudar, mas eles são diferentes). O Solidity aloca 0x00
- 0x40
como scratch space
(espaço de trabalho). Esta área de memória não tem garantia de estar vazia e é utilizada para certas operações. 0x40
- 0x60
armazena a localização do que é conhecido como o free memory pointer
(ponteiro de memória livre), que é usado para escrever algo novo para a memória. 0x60
- 0x80
é deixado vazio como uma lacuna. 0x80 é onde começamos nossas operações. A memória não empacota valores. A recuperação de valores do armazenamento será armazenada em sua própria sequência de 32 bytes (i.e 0x80-0xa0
).
A memória é utilizada para as seguintes operações:
- Retornar valores para chamadas externas
- Definir valores de funções para chamadas externas
- Obter valores de chamadas externas
- Reverter com uma string de erros
- Registrar mensagens
- Criar hash com
keccak256()
- Criar outros contratos inteligentes
Aqui estão algumas instruções úteis de Yul para memória!
Vamos verificar mais algumas estruturas de dados!
Estruturas e arrays fixos na verdade se comportam da mesma forma, mas como já vimos os arrays fixos na seção de armazenamento, vamos olhar as structs aqui. Veja a seguinte struct.
struct Var10 {
uint256 subVar1;
uint256 subVar2;
}
Nada de anormal nisso, apenas uma simples struct. Agora vamos olhar um pouco de código!
function getStructValues() external pure returns(uint256, uint256) {
// inicializar struct
Var10 memory s;
s.subVar1 = 32;
s.subVar2 = 64;
assembly {
return( 0x80, 0xc0 )
}
}
Aqui estamos configurando s.subVar1
para o endereço de memória 0x80
- 0xa0
e s.subVar2
para o endereço de memória 0xa0
- 0xc0
. É por isso que estamos retornando 0x80
- 0xc0
. Aqui está uma tabela do layout da memória logo antes do final da transação.
Coisas para tirar disso:
-
0x00
-0x40
estão vazias para espaço de trabalho -
0x40
nos dá o ponteiro de memória livre - O Solidity deixa um espaço para
0x60
-
0x80
e0xa0
são usados para armazenar os valores da struct -
0xc0
é o novo ponteiro de memória livre.
Nesta última parte da seção de memória, quero mostrar como funcionam os arrays dinâmicos na memória. Vamos passar [0, 1, 2, 3]
como parâmetro arr
para este exemplo. Como um bônus adicional para este exemplo, vamos adicionar um elemento extra ao array. Tenha cuidado ao fazer isso na produção, pois você pode sobrescrever uma variável de memória diferente. Aqui está o código!
function getDynamicArray(uint256[] memory arr) external view returns (uint256[] memory) {
assembly {
// onde o array é armazenado na memória (0x80)
let location := arr
// comprimento do array é armazenado em arr (4)
let length := mload(arr)
// obtém o próximo endereço de memória disponível
let nextMemoryLocation := add( add( location, 0x20 ), mul( length, 0x20 ) )
// armazena um novo valor para a memória
mstore(nextMemoryLocation, 4)
// incrementa o comprimento de 1
length := add( length, 1 )
// armazena novo valor de comprimento
mstore(location, length)
// atualiza o ponteiro de memória livre
mstore(0x40, 0x140)
return ( add( location, 0x20 ) , mul( length, 0x20 ) )
}
}
O que estamos fazendo aqui é buscar onde o array é armazenado na memória. Então, obtemos o comprimento do array, que é armazenado no primeiro endereço da memória do array. Para ver o próximo local disponível, estamos adicionando 32 bytes ao local (pular o comprimento do array) e multiplicando o comprimento do array por 32 bytes. Isto nos leva para o próximo endereço de memória após nosso array. Aqui, vamos armazenar nosso novo valor (4). Em seguida, atualizamos o comprimento do array por um. Depois disso, temos que atualizar o ponteiro de memória livre. Finalmente, retornamos o array.
Vamos examinar mais uma vez o layout da memória.
Isso conclui a seção sobre memória!
Chamadas de Contrato
Na seção final deste artigo, veremos como funcionam as chamadas de contrato em Yul.
Antes de mergulharmos em alguns exemplos, precisamos primeiro aprender mais algumas operações em Yul. Vamos dar uma olhada.
Ok, agora vamos olhar alguns novos contratos para estes exemplos. Primeiro, vejamos o contrato que vamos chamar.
pragma solidity^0.8.17;
contract CallMe {
uint256 public var1 = 1;
uint256 public var2 = 2;
function a(uint256 _var1, uint256 _var2) external payable returns(uint256, uint256) {
//requer 1 ether para ser enviado para o contrato
require(msg.value >= 1 ether);
// atualiza var1 & var2
var1 = _var1;
var2 = _var2;
// retorna var1 & var2
return (var1, var2);
}
function b() external view returns(uint256, uint256) {
return (var1, var2);
}
}
Não é o contrato mais avançado, mas vamos revisá-lo de qualquer forma. Este contrato tem duas variáveis de armazenamento var1
e var2
que são armazenadas nos slots de armazenamento 1 e 2 respectivamente. A função a()
exige que o usuário envie pelo menos 1 ether para o contrato. Caso contrário, ela reverte. A seguir, a função a()
atualiza a var1
e a var2
e as retorna. A função b()
simplesmente lê as variáveis var1
e var2
e as retorna.
Antes de passarmos para nosso contrato que chama o contrato CallMe
, precisamos de um minuto para entender os seletores de funções. Vejamos os seguintes dados de chamada para uma transação 0x773d45e000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000002
. Os primeiros 4 bytes do calldata é o que se chama seletor de funções (0x773d45e0
). É assim que a EVM sabe que função você quer chamar. Nós derivamos o seletor de funções obtendo os primeiros 4 bytes do hash de uma string da assinatura da função. Assim, a assinatura da função a()
seria a(uint256,uint256)
. Pegando o hash dessa string temos 0x773d45e097aa76a22159880d254a5f1db8365bc2d0f0987a82bda7dfd3b9c8aa
. Olhando para os primeiros 4 bytes, vemos que é igual a 0x773d45e0
. Observe a falta de espaços na assinatura. Isto é importante porque a adição de espaços nos fornecerá um hash completamente diferente. Você não precisa se preocupar em obter os seletores para nossos exemplos de código, eu os fornecerei.
Vamos começar olhando o layout de armazenamento.
uint256 public var1;
uint256 public var2;
bytes4 selectorA = 0x773d45e0;
bytes4 selectorB = 0x4df7e3d0;
Observe como a var1
& var2
tem o mesmo layout do contrato CallMe
. Você deve lembrar que eu disse que o layout tem que ser o mesmo do nosso outro contrato para que o delegatecall()
funcione corretamente. Nós satisfazemos essas necessidades e podemos ter outras variáveis (selectorA
& selectorB
) desde que nossas novas variáveis sejam anexadas ao final. Isto evita qualquer colisão de armazenamento.
Agora estamos prontos para fazer nossa primeira chamada de contrato. Vamos começar com algo simples, staticcall()
. Aqui está nossa função.
function getVars(address _callMe) external view returns(uint256, uint256) {
assembly {
// carrega o slot 2 de memória
let slot2 := sload(2)
// desliga o selectorA
let funcSelector := shr( 32, slot2)
// armazena o selectorB para o endereço de memória 0x80
mstore(0x00, funcSelector)
// chamada estática CallMe
let result := staticcall(gas(), _callMe, 0x1c, 0x20, 0x80, 0xc0)
// verifica se a chamada foi bem sucedida, caso contrário, reverter
if iszero(result) {
revert(0,0)
}
// retorna valores da memória
return (0x80, 0xc0)
}
}
A primeira coisa que precisamos fazer é recuperar o seletor da função b()
do armazenamento. Fazemos isso, carregando o slot 2 (ambos os seletores estão empacotados em um slot). Então deslocamos para a direita 4 bytes (32 bits) para isolar o selectorB
. A seguir, armazenaremos o seletor de funções no espaço de trabalho da memória. Agora podemos fazer nossa chamada estática. Estamos passando pelo gas()
para esses exemplos, mas você pode querer especificar a quantidade de gas, se desejar. Estamos passando o parâmetro _callMe
para o endereço do contrato. 0x1c
e 0x20
dizem que queremos passar os últimos 4 bytes do que armazenamos para o espaço de trabalho. Isto deve fazer sentido porque os seletores de função são 4 bytes, mas a memória funciona em série de 32 bytes (novamente, lembre-se de que armazenamos da direita para a esquerda). Os dois últimos parâmetros para staticcall()
especificam que queremos armazenar os dados de retorno nos endereços de memória 0x80
- 0xc0
. A seguir, verificamos se a chamada de função foi bem sucedida; caso contrário, retornamos sem dados. Lembre-se, chamadas bem sucedidas retornarão um 1. Finalmente, retornamos nossos dados da memória, e vemos os valores 1 e 2.
A seguir, vamos ver call()
. Vamos chamar a função a()
do CallMe
. Lembre-se de enviar pelo menos 1 ether para o contrato! Vou passar 3 e 4 como _var1
& _var2
para esse exemplo. Aqui está o código.
function callA(address _callMe, uint256 _var1, uint256 _var2) external payable returns (bytes memory) {
assembly {
// carrega o slot 2
let slot2 := sload(2)
// isola selectorA
let mask := 0x000000000000000000000000000000000000000000000000000000000ffffffff
let funcSelector := and(mask, slot2)
// armazena a função selectorA
mstore(0x80, funcSelector)
// copia calldata para o endereço de memória 0xa0
// deixa de fora o seletor de funções e _callMe
calldatacopy(0xa0, 0x24, sub( calldatasize(), 0x20 ) )
// chama o contrato
let result := call(gas(), _callMe, callvalue(), 0x9c, 0xe0, 0x100, 0x120 )
// verifica se a chamada foi bem sucedida, caso contrário, reverter
if iszero(result) {
revert(0,0)
}
// retorna valores da memória
return (0x100, 0x120)
}
}
Então, semelhante ao nosso último exemplo, temos que carregar o slot2. Desta vez, no entanto, vamos mascarar selectorB
para isolar o selectorA
. Agora vamos armazenar o seletor em 0x80
. Como precisamos dos parâmetros do calldata, vamos usar calldatacopy()
. Estamos dizendo para a calldatacopy()
para armazenar o calldata no endereço de memória 0xa0
. Também estamos dizendo para a calldatacopy()
para pular os primeiros 36 bytes. Os primeiros 4 bytes são o seletor de função para callA()
e os 32 bytes seguintes são o endereço da callMe
(vamos usar isso em um minuto). A última coisa que falamos para a calldatacopy()
é para armazenar o tamanho do calldata menos 36 bytes. Agora estamos prontos para fazer nossa chamada de contrato. Como da última vez, nós passamos pelo gas()
e _callMe
. No entanto, desta vez passamos pelo nosso calldata do 0x9c
(últimos 4 bytes da série de memória 0x80
) - 0xe0
e armazenamos nossos dados no endereço de memória 0x100
- 0x120
. Mais uma vez, verificamos se a chamada foi bem sucedida e retornamos nosso output. Se verificarmos o contrato CallMe,
veremos que os valores foram atualizados para 3 e 4 com sucesso.
Para esclarecimento adicional do que está acontecendo, aqui está o layout da memória, antes de voltarmos.
Em nossa última seção, veremos a delegatecall()
. O código ficará quase idêntico com apenas uma mudança.
function delgatecallA(address _callMe, uint256 _var1, uint256 _var2) external payable returns (bytes memory) {
assembly {
// carrega o slot 2
let slot2 := sload(2)
// isola o selectorA
let mask := 0x000000000000000000000000000000000000000000000000000000000ffffffff
let funcSelector := and(mask, slot2)
// armazena a função selectorA
mstore(0x80, funcSelector)
// copia o calldata para o endereço de memória 0xa0
// deixa o seletor de função e a _callMe
calldatacopy(0xa0, 0x24, sub( calldatasize(), 0x20 ) )
// chama o contrato
let result := delegatecall(gas(), _callMe, 0x9c, 0xe0, 0x100, 0x120 )
// verifica se a chamada foi bem sucedida, caso contrário, reverter
if iszero(result) {
revert(0,0)
}
// retorna os valores da memória
return (0x100, 0x120)
}
}
A única alteração que fizemos foi mudar de call()
para delegatecall()
e remover callvalue()
. Não precisamos da callvalue()
, porque a delegate call executa o código de CallMe
dentro do seu próprio estado. Entretanto, a declaração de require()
em a()
está verificando se foi enviado o ether para o contrato Caller
. Se nós verificarmos a var1
e a var2
no CallMe
, não vemos nenhuma mudança. Contudo, a var1
e a var2
em nosso contrato Caller
foi atualizada com sucesso.
Isso encerra nossa seção sobre chamadas de contratos, juntamente com este guia para iniciantes em Yul. Para aprofundar seu conhecimento sobre Yul, leia a documentação para Yul e o Ethereum Yellow Paper, no link abaixo.
Documentação Yul: https://docs.soliditylang.org/en/v0.8.17/yul.html \
Ethereum Yellow Paper: https://ethereum.github.io/yellowpaper/paper.pdf
Se você tiver alguma pergunta ou quiser que eu faça um tutorial sobre um tópico diferente, por favor, deixe um comentário abaixo.
Se você gostaria de me ajudar a fazer tutoriais aqui é meu endereço no Ethereum: 0xD5FC495fC6C0FF327c1E4e3Bccc4B5987e256794.
Esse artigo foi escrito por Marq e traduzido por Fátima Lima. O original pode ser lido aqui.
Latest comments (0)