WEB3DEV

Cover image for Guia para Iniciantes em Yul
Fatima Lima
Fatima Lima

Posted on

Guia para Iniciantes em Yul

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
 }
Enter fullscreen mode Exit fullscreen mode

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!

Image description

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;
}
Enter fullscreen mode Exit fullscreen mode

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;

}
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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!

Image description

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);
}
Enter fullscreen mode Exit fullscreen mode

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;

}
Enter fullscreen mode Exit fullscreen mode

Agora vamos ver algumas estruturas de dados mais complexas!

// slot 4 & 5
uint128[4] var6 = [0,1,2,3];
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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;

}
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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;

}
Enter fullscreen mode Exit fullscreen mode

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)
   }

}
Enter fullscreen mode Exit fullscreen mode

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.

Image description

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;
}
Enter fullscreen mode Exit fullscreen mode

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;

}
Enter fullscreen mode Exit fullscreen mode

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;

}
Enter fullscreen mode Exit fullscreen mode

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);

}
Enter fullscreen mode Exit fullscreen mode

Output: \
ans1: 0x0000ffff00000000000000000000000000000000000000000000000000000000 \
ans2
: 0x00000000000000000000000000000000000000000000000000000000ffff0000

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);

   }
Enter fullscreen mode Exit fullscreen mode

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)
   }

}
Enter fullscreen mode Exit fullscreen mode

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!

Image description

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;
}
Enter fullscreen mode Exit fullscreen mode

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 )
   }

}
Enter fullscreen mode Exit fullscreen mode

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.

Image description

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 e 0xa0 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 ) )

   }

}
Enter fullscreen mode Exit fullscreen mode

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.

Image description

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.

Image description

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);
   }

}
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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)

   }

}
Enter fullscreen mode Exit fullscreen mode

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)

   }

}
Enter fullscreen mode Exit fullscreen mode

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.

Image description

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)

   }

}
Enter fullscreen mode Exit fullscreen mode

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.

Oldest comments (0)