Neste artigo, usaremos o Yul para otimizar nossos contratos inteligentes para economizar gás. Veremos três contratos inteligentes diferentes desenvolvidos para jogar o clássico jogo Pedra, Papel e Tesoura. Um contrato será escrito puramente em Solidity, outro contrato será escrito em Solidity com montagem em linha e o último contrato será escrito puramente em Yul. Se você não está familiarizado com o Yul, confira o artigo que escrevi sobre o Yul para entender melhor o que é e como funciona.
Guia para iniciantes do Yul:https://medium.com/coinsbench/beginners-guide-to-yul-12a0a18095ef \
Repositório do Github com Pedra, Papel e Tesoura:https://github.com/marjon-call/Rock-Paper-Scissors
Ok, vamos mergulhar na estrutura do jogo!
Estrutura do jogo
Para jogar, o usuário precisa chamar createGame(address _player1, address _player2) external
. Os dois endereços de parâmetro representam os dois jogadores que jogam o jogo. Depois que o jogo é criado, os jogadores podem fazer seus movimentos chamando playGame(uint8 _move) external payable
. Esta função leva um uint8
que deve estar entre 1 - 3. 1 representa pedra, 2 representa papel e 3 representa tesoura. O custo para jogar é de 0,05 ether e cada jogador só pode fazer um movimento. Uma vez que ambos os jogadores tenham feito sua jogada, evaluateGame() private
é chamado para verificar o resultado do jogo e distribui o ether para o vencedor, ou divide o ether se o jogo estiver empatado. Apenas um jogo pode ser jogado por vez. A variável de armazenamento bool public gameInProgress
é usado para acompanhar isso. Também temos variável de armazenamento uint256 public gameStart
para acompanhar quando o jogo foi iniciado e variável de armazenamento uint256 public gameLength
para manter o controle de quanto tempo de duração queremos que o nosso jogo tenha. Se o jogo estiver demorando muito, qualquer espectador pode chamar TerminateGame() external
para que eles possam jogar um jogo. Nesse caso, o ether é enviado a um jogador que já fez sua jogada.
Solidity
Primeiro, vamos dar uma olhada em nosso contrato inteligente Solidity e descrever a funcionalidade. Aqui estão nossas variáveis de armazenamento.
// slot 0
uint256 public gameStart;
// slot 1
uint256 public gameCost;
// slot 2
address public player1;
uint8 private player1Move;
// slot 3
address public player2;
uint8 private player2Move;
bool public gameInProgress;
bool public lockGame;
// slot 4
uint256 public gameLength;
event GameOver(address Winner, address Loser);
event GameStart(address Player1, address Player2);
event TieGame(address Player1, address Player2);
event GameTerminated(address Loser1, address Loser2)
Observe que estamos empacotando nossas variáveis para economizar nos custos de gás. Vamos nos aprofundar nisso mais tarde, mas por enquanto apenas esteja ciente disso.
Ótimo, agora vamos olhar para o construtor.
constructor() {
gameCost = 0.05 ether;
gameLength = 28800;
}
}
Tudo o que estamos fazendo aqui é definir gameCost
para 0,05 ether, e definindo gameLength
para 28800 blocos.
Agora vamos ver createGame()
.
// permite que os usuários criem um novo jogo
function createGame(address _player1, address _player2) external {
require(gameInProgress == false, "Game still in progress.");
gameInProgress = true;
gameStart = block.number;
player1 = _player1;
player2 = _player2;
}
A primeira coisa que fazemos é verificar se um jogo está em andamento. Se for, nós revertemos. Caso contrário, definimos nossas variáveis de armazenamento para configurar nosso jogo.
Vamos dar uma olhada em nosso armazenamento para entender melhor o que está acontecendo nos bastidores. Se você quiser acompanhar, aqui está a função que estou usando para obter o layout de armazenamento.
function getStorage() public view returns(bytes32, bytes32, bytes32, bytes32) {
assembly {
mstore(0x00, sload(0))
mstore(0x20, sload(1))
mstore(0x40, sload(2))
mstore(0x60, sload(3))
return(0x00, 0x80)
}
}
Vamos falar sobre o slot de armazenamento 3 antes de prosseguir. Empacotado da direita para a esquerda, vemos que os primeiros 20 bytes são o endereço do Jogador 2 (0x5b38da6a701c568545dcfcb03fcb875f56beddc4
). Em seguida, vemos 1 byte vazio (0x00
), é aqui que armazenaremos o movimento do Jogador 2. Depois disso vemos 0x01
para gameInProgress
, que é equivalente a true. Depois disso outro byte vazio (0x00
), porque lockgame
está definido como falso por enquanto. Então vemos 0x7080
, que é equivalente a 28800 em decimal (o valor que definimos gameLength
no construtor).
Vamos também dar uma olhada rápida na memória.
Não realizamos nenhuma operação na memória, então esperamos que fique assim.
Ótimo, agora veremos como playGame()
funciona!
function playGame(uint8 _move) external payable {
require(lockGame == false);
require(gameInProgress == true, "Jogo não em andamento.");
require(msg.value >= gameCost, "O jogador não enviou ether suficiente para jogar.");
require(_move > 0 && _move < 4, "Movimento inválido");
lockGame = true;
if (msg.sender == player1 && player1Move == 0) {
player1Move = _move;
} else if(msg.sender == player2 && player2Move == 0) {
player2Move = _move;
} else {
require(1 == 0, "Usuário não autorizado a fazer movimento.");
}
if (player1Move != 0 && player2Move != 0) {
evaluateGame();
}
lockGame = false;
}
Ok, então a primeira coisa que vamos fazer é usar declarações require()
para verificar se estamos jogando de acordo com as regras. O primeiro require()
verifica se o jogo está bloqueado. O segundo require()
verifica se um jogo foi criado. O terceiro require()
está verificando se o jogador enviou ether suficiente para jogar o jogo. O último require()
verifica se o jogador enviou uma jogada válida. Depois disso, fechamos o jogo. Então, temos uma declaração if
verificando se o jogador 1 está fazendo seu movimento. Também verificamos se eles já fizeram um movimento verificando se o valor de player1Move
está definido como 0 (lembre-se de que o Solidity inicializa os valores como 0). Observe que verificamos o primeiro jogador. Consideramos que é mais provável que essa condição não seja satisfeita, ao fazê-lo economizamos gás por não precisar verificar se o Jogador 1 já fez sua jogada (menos operações, menos custos de gás). Se as condições forem satisfeitas, definimos o movimento do Jogador 1. Em seguida, fazemos as mesmas verificações para o Jogador 2. Se nenhuma das declarações if
forem satisfeitas, nós a revertemos. Caso contrário, verificaremos se ambos os movimentos foram feitos. Se tiverem sido, chamamos evaluateGame()
. Finalmente, desbloqueamos o jogo.
Vejamos como fica o armazenamento se o jogador 1 se mover primeiro com a pedra.
Observe que a única alteração está no slot 2. Lendo da direita para a esquerda, após o endereço do jogador 1, vemos 0x01
(1 em decimal), que equivale a pedra. Também é importante observar que isso ocorre no final da transação, portanto lockgame
já está definido de volta para 0.
Novamente, a memória não é usada, então parece a mesma de quando chamamos createGame()
.
Agora vamos ver o que acontece no armazenamento logo antes de chamar evaluateGame()
quando o Jogador 2 faz sua jogada com tesoura.
A única diferença aqui está no slot 3. Vemos que depois de player2
, player2Address
está configurado para 0x03
(decimal 3), que é igual a tesoura para o nosso jogo. Observe também que entre gameInProgress
e gameLength
, lockgame
foi definido para 0x01
(verdadeiro em valor booleano).
Agora que sabemos como playGame()
funciona, vamos ver como evaluateGame()
funciona.
function evaluateGame() private {
address _player1 = player1;
uint8 _player1Move = player1Move;
address _player2 = player2;
uint8 _player2Move = player2Move;
if (_player1Move == _player2Move) {
_player1.call{value: address(this).balance / 2}("");
_player2.call{value: address(this).balance}("");
emit TieGame(_player1, _player2);
} else if (
(_player1Move == 1 && _player2Move == 3 ) ||
(_player1Move == 2 && _player2Move == 1) ||
(_player1Move == 3 && _player2Move == 2)) {
_player1.call{value: address(this).balance}("");
emit GameOver(_player1, _player2);
} else {
_player2.call{value: address(this).balance}("");
emit GameOver(_player2, _player1);
}
gameInProgress = false;
player1 = address(0);
player2 = address(0);
player1Move = 0;
player2Move = 0;
gameStart = 0;
}
A primeira coisa que fazemos em evaluateGame()
é armazenar variáveis de armazenamento na pilha. Para entender por que estamos fazendo isso, vamos falar um pouco sobre os custos do gás de armazenamento. Ao se referir a slots de armazenamento, há dois estados diferentes: armazenamento frio (Cold storage) e armazenamento quente (warm storage). Cold storage é quando um slot não foi acessado anteriormente na transação (já acessamos essas variáveis em playGame()
). O acesso ao Cold storage é muito caro e custa 2.100 gás. Depois de acessar um slot de armazenamento em uma transação, ele é chamado de armazenamento quente. O acesso ao armazenamento quente custa 100 gás, o que ainda é muito caro. Essa é uma das vantagens de empacotar variáveis, pois você pode acessar o armazenamento quente em vez do armazenamento frio, mesmo que seja a primeira vez que toca nessa variável específica. Acessar uma variável da pilha pode ter custos diferentes, mas no exemplo acima fica em torno de 10 gás por leitura. Esta é uma economia significativa, portanto, lembre-se disso ao escrever contratos inteligentes.
A próxima seção do código verifica quem ganha o jogo. A Primeira declaração if
está verificando se há um empate. Nesse caso, o contrato envia metade do ether para ambos os jogadores. Em seguida, usamos uma longa declaração if
para verificar as situações em que o Jogador 1 vence. Se alguma dessas condições for atendida, enviamos o ether ao Jogador 1. Caso contrário, o Jogador 2 deve ter vencido e o Jogador 2 é recompensado com o ether.
A última seção desta função redefine o estado do contrato inteligente para permitir que os usuários joguem um novo jogo. Se você chamar getStorage()
, você verá que o layout é o mesmo de antes quando chamamos creatGame()
. Observe como estamos redefinindo o slot 0 e o slot 2 para ficarem vazios. Isso realmente nos dá um reembolso de gás! Para cada slot definido como 0, você recebe 15.000 gás. No entanto, o reembolso máximo é de ⅕ do custo do gás da transação.
A última função que precisamos revisar em nosso contrato Solidity é terminateGame()
.
function terminateGame() external {
require(gameStart + gameLength < block.number, "O jogo ainda tem tempo.");
require(gameInProgress == true, "Jogo não iniciado");
if(player1Move != 0) {
player1.call{value: address(this).balance}("");
} else if(player2Move != 0) {
player2.call{value: address(this).balance}("");
}
gameInProgress = false;
player1 = address(0);
player2 = address(0);
player1Move = 0;
player2Move = 0;
gameStart = 0;
emit GameTerminated(player1, player2);
}
Nossas declarações require()
verificam se o jogo passou do tempo alocado e se há um jogo para terminar. Em seguida, verificamos se algum jogador já fez uma jogada. Se tiverem feito, damos a eles o ether dos contratos. Por fim, redefinimos o estado do contrato da maneira que fizemos em evaluateGame()
.
Contrato Híbrido
Agora vamos ver como podemos usar o Yul para economizar em alguns custos de gás!
O contrato híbrido terá o mesmo layout de armazenamento do nosso contrato Solidity puro. O construtor também permanecerá o mesmo.
Aqui está o nossa função createGame()
atualizada.
// permite que os usuários criem um novo jogo // permite que os usuários criem um novo jogo
function createGame(address _player1, address _player2) external {
require(gameInProgress == false, "Jogo ainda em progresso.");
assembly {
sstore(0, number())
sstore(2, _player1)
let glAndGip := or(0x0000000000000000000001000000000000000000000000000000000000000000, sload(3))
sstore(3, or(glAndGip, _player2))
}
}
O require()
é o mesmo, mas depois disso mergulhamos em algum Yul. Primeiro, armazenamos o número do bloco no slot de armazenamento 0. Esta é a configuração gameStart
. Em seguida, armazenamos o endereço do jogador 1 no slot 2. Depois, precisamos formatar nossos dados para empacotá-los no slot 3. O slot de armazenamento 3 já está armazenando gameLength
neste momento. Então tudo o que temos que fazer é carregar o slot 3 e or()
com um valor de 32 bytes que tem true definido para gameInProgress
(0x0000000000000000000000000100000000000000000000000000000000000000000000
). Nosso último passo é usar ou()
para este valor com o endereço do Player 2 e armazená-lo. Se você chamar getStorage()
, você verá os mesmos resultados de quando usamos o contrato inteligente Solidity.
playGame()
vai parecer idêntico ao que era no contrato Solidity. No entanto, evaluateGame()
tem uma grande mudança nele.
function evaluateGame() private {
address _player1 = player1;
uint8 _player1Move = player1Move;
address _player2 = player2;
uint8 _player2Move = player2Move;
if (_player1Move == _player2Move) {
_player1.call{value: address(this).balance / 2}("");
_player2.call{value: address(this).balance}("");
emit TieGame(_player1, _player2);
} else if ((_player1Move == 1 && _player2Move == 3 ) || (_player1Move == 2 && _player2Move == 1) || (_player1Move == 3 && _player2Move == 2)) {
_player1.call{value: address(this).balance}("");
emit GameOver(_player1, _player2);
} else {
_player2.call{value: address(this).balance}("");
emit GameOver(_player2, _player1);
}
assembly {
sstore(0,0)
sstore(2, 0)
let gameLengthShifted := shr(mul(23,8), sload(3))
let gameLengthVal := shl(mul(23,8), gameLengthShifted)
sstore(3, gameLengthVal)
}
}
A primeira parte do contrato é a mesma, mas o último bloco de montagem é onde otimizamos nosso código. Veremos como isso economiza combustível em um momento, mas, enquanto isso, vamos terminar de revisar o contrato híbrido.
Por último, vejamos terminateGame()
.
function terminateGame() external {
require(gameStart + gameLength < block.number, "O jogo tem tempo restante.");
require(gameInProgress == true, "O jogo não começou");
if(player1Move != 0) {
player1.call{value: address(this).balance}("");
} else if(player2Move != 0) {
player2.call{value: address(this).balance}("");
}
assembly {
sstore(0,0)
sstore(2, 0)
let gameLengthShifted := shr(mul(23,8), sload(3))
let gameLengthVal := shl(mul(23,8), gameLengthShifted)
sstore(3, gameLengthVal)
}
emit GameTerminated(player1, player2);
}
Você provavelmente notou que a mudança é muito semelhante. Novamente, alteramos apenas uma seção, o bloco de código de montagem. Então, por que fizemos isso? Bem, a resposta é que, quando estamos limpando nossos slots de armazenamento, isso nos dá o poder de limpar duas variáveis compactadas com uma operação, em vez de ter que limpar manualmente cada uma delas, como no contrato do Solidity.
Agora que temos dois contratos, vamos comparar os custos de gás de chamar para cada um.
Reduzimos o custo de cada chamada de função, incluindo os custos de implantação (1.240.438 x 1.171.912)! Vale ressaltar também que para playGame()
, se o segundo jogador estiver chamando a função que chama evaluateGame()
. Portanto, a diferença de gás de 1.232 entre os dois valores máximos de gás é um grande problema para os usuários.
Agora que encerramos esta seção, estamos prontos para escrever nosso contrato puramente em Yul!
Yul
Quero começar esta seção dizendo que, se você estiver acompanhando, recomendo que use o remix. Hardhat não oferece suporte a contratos Yul puros, então você terá problemas para compilar seu contrato inteligente. Além disso, ao trabalhar com um contrato puramente escrito em Yul, você não pode simplesmente chamar uma função como faria no Solidity. Por exemplo, em vez de chamar playGame()
você teria que estruturar seus dados de chamada manualmente e chamar o seletor de função 0x985d4ac3
. Então, primeiro vamos ver como precisamos estruturar nosso calldata para que possamos chamar nosso contrato.
O seletor de função de createGame()
é 0xa6f979ff
. Se você não se lembra de como derivar um seletor de função, verifique meu “Guia para iniciantes do Yul”, cujo link está no início deste artigo. Em seguida, precisamos ver como passamos nossos dois argumentos de endereço anteriores. Se você se lembra, a memória funciona em séries de 32 bytes. Então você precisa formatar ambos os endereços de 20 bytes para 32 bytes preenchendo o lado esquerdo com 12 bytes vazios. Aqui está um exemplo de como nossos dados de chamada serão: 0xa6f979ff0000000000000000000000005b38da6a701c568545dcfcb03fcb875f56beddc40000000000000000000000000ab8483f64d9c6d1ecf9b849ae677dd3125835dd3125835
Function Selector
: 0xa6f979ff
_player1:0000000000000000000000005b38da6a701c568545dcfcb03fcb875f56beddc4
_player2:000000000000000000000000ab8483f64d9c6d1ecf9b849ae677dd3315835cb2
playGame()
é um pouco mais simples porque só temos uma variável. Embora seja um uint8, como há apenas uma variável e não a estamos compactando, ela ocupa 32 bytes. Aqui está a aparência do nosso calldata se usarmos pedra:
0x985d4ac30000000000000000000000000000000000000000000000000000000000000000001
Function Selector: 0x985d4ac3
_move: 0000000000000000000000000000000000000000000000000000000000000001
O último é terminateGame()
. Não tem parâmetros, então só precisamos usar o seletor de função para o nosso calldata: 0x97661f31
Ótimo, agora estamos prontos para mergulhar no código!
object "RockPaperScissorsYul" {
code {
sstore(1, 0xB1A2BC2EC50000)
sstore(3, 0x000000000000000B400000000000000000000000000000000000000000000000)
datacopy(0, dataoffset("runtime"), datasize("runtime"))
return(0, datasize("runtime"))
}
object "runtime" {
// Storage Layout:
// slot 0 : gameStart
// slot 1 : gameCost
// slot 2 : bytes 13 - 32 : player1
// slot 2 : bytes 12 : player1Move
// slot 3 : bytes 13 - 32 : player2
// slot 3 : bytes 12 : player2Move
// slot 3 : bytes 11 : gameInProgress
// slot 3 : bytes 10 : lockGame
// slot 3 : bytes 9 - 8 : gameLength
// rest of code
}
}
object "RockPaperScissorsYul" {}
está declarando nosso contrato inteligente. Todo o código será executado dentro dele. O primeiro code {}
é o que um construtor seria em Solidity. Dentro de nós estamos armazenando gameCost
como 0,05 ether no slot 1. Então, estamos armazenando gameLength
. Observe que defini um valor diferente desta vez, sinta-se à vontade para defini-lo como preferir. As duas próximas linhas estão copiando o runtime code e retornando-o. Na sequência, o object "runtime" {}
é onde colocamos nosso código que queremos chamar no runtime. Por fim, coloquei o layout de armazenamento. Eu recomendo fazer isso ao escrever contratos no Yul, porque ajuda você a controlar onde todas as suas variáveis estão armazenadas.
Como esse código é longo e formatado de forma diferente do Solidity, iremos analisá-lo em partes.
code {
let callData := calldataload(0)
let selector := shr(0xe0, callData)
switch selector
// createGame(address, address)
case 0xa6f979ff {
// obtém gameInProgress do armazenamento
let gameInProgress := and(0xff, shr( mul( 21, 8), sload(3) ) )
// se o jogo em andamento for definido, reverta
if eq(gameInProgress, 1) {
revert(0,0)
}
// copia calldata para a memória sem seletor de função
calldatacopy(0, 4, calldatasize())
// obtém os endereços 1 e 2
let address1 := and(0x000000000000000000000000ffffffffffffffffffffffffffffffffffffffff, mload(0x00))
let address2 := and(0x000000000000000000000000ffffffffffffffffffffffffffffffffffffffff, mload(0x20))
// armazena o bloco de início do jogo e player1 no armazenamento
sstore(0, number())
sstore(2, address1)
// empacota gameLength, gameInProgress e player2 no slot3
let gameLengthShifted := shr(mul(23,8), sload(3))
let gameLengthVal := shl(mul(23,8), gameLengthShifted)
let glAndGip := or(0x0000000000000000000001000000000000000000000000000000000000000000, gameLengthVal)
sstore(3, or(glAndGip, address2))
}
// mais casos
}
code{}
é o código que iremos rodar, vamos colocar o resto do nosso código dentro dele. calldataload(0)
está carregando os primeiros 32 bytes de dados da chamada. Então estamos deslocando 28 bytes para a direita para isolar o seletor de função.
Uma grande operação em Yul que não é permitida em Solidity é a declaração switch{}
. Estamos usando uma instrução switch para informar qual função está sendo chamada. Observe o primeiro caso, 0xa6f979ff
é o seletor de funções de criarJogo()
. A primeira coisa que fazemos dentro do nosso primeiro caso é verificar se um jogo está em andamento. Fazemos isso carregando o slot 3 na pilha, para evitar o uso de outrossload()
mais tarde. Então estamos mudando slot3
para a direita por 21 bytes para formatar gameInProgress
até o 32º byte. Em seguida, usamos uma máscara de byte único para isolar nossa variável. Em seguida, verificamos se é 1 (verdadeiro em Yul) e revertemos se essa condição for satisfeita. Em seguida, carregamos o restante de nossos dados de chamada. Depois disso usamos and()
e uma máscara para isolar nossos endereços e atribuí-los a variáveis na pilha. Em seguida, armazenamos o número do bloco para gameStart
e address1
para player1
. Por fim, estamos configurando gameInProgress
como verdadeiro usando or()
com slot3
e 0x0000000000000000000000000100000000000000000000000000000000000000000000
. Estamos então usando mais um or()
para embalar address2
e, em seguida, armazenando esses valores no slot 3.
Agora, vamos olhar para o segundo caso do switch, playGame()
.
// playGame(uint8)
case 0x985d4ac3 {
calldatacopy(0, 4, calldatasize())
// armazena variáveis de movimento e slot para empilhar
let move := mload(0x00)
let slot2 := sload(2)
let slot3 := sload(3)
// obtém lockGame do armazenamento
let lockGame := and( 0xff, shr( mul(22, 8), slot3) )
// verifica se o jogo não está bloqueado
if eq(lockGame, 1) {
revert(0,0)
}
// obtém gameInProgress do armazenamento
let gameInProgress := and(0xff, shr( mul( 21, 8), slot3 ) )
// se o jogo em andamento não for definido, reverta
if iszero(gameInProgress) {
revert(0,0)
}
// se não houver ether suficiente enviado reverta
if gt(sload(1), callvalue()) {
revert(0,0)
}
// se o movimento for inválido reverta
if lt(move, 1) {
revert(0,0)
}
if gt(move, 3) {
revert(0,0)
}
obtém o movimento do jogador 1 e o movimento do jogador 2 do armazenamento
let player1Move := shr( mul(20, 8), slot2 )
let player2Move := and(0xff, shr( mul(20, 8), slot3 ) )
// obtém player1 e player2
let player1 := and(0x000000000000000000000000ffffffffffffffffffffffffffffffffffffffff, slot2 )
let player2 := and(0x000000000000000000000000ffffffffffffffffffffffffffffffffffffffff, slot3 )
// verifica se o player1 ja fez o movimento, caso contrário, define o player1Move
if eq(caller(), player1) {
if gt(player1Move, 0) {
revert(0,0)
}
let moveShifted := shl( mul(20, 8), move)
sstore(2, or(moveShifted, slot2) )
// checa se ambos os jogadores fizeram um movimento
if gt(player2Move, 0) {
// bloqueia a reentrada
sstore(3, or(0x0000000000000000000100000000000000000000000000000000000000000000, sload(3) ))
evaluateGame()
// desbloqueia a reentrada
sstore(3, and(0xffffffffffffffffff00ffffffffffffffffffffffffffffffffffffffffffff, sload(3)))
stop()
}
}
// checa se o player2 ja fez um movimento, se não define player2Move
if eq(caller(), player2) {
if gt(player2Move, 0) {
revert(0,0)
}
let moveShifted := shl( mul(20, 8), move)
let newSlot3Value := or(moveShifted, slot3)
// checa se ambos os jogadores fizeram um movimento
if gt(player1Move, 0) {
sstore(3, or(0x0000000000000000000100000000000000000000000000000000000000000000, newSlot3Value) )
evaluateGame()
// desbloqueia a reentrada
sstore(3, and(0xffffffffffffffffff00ffffffffffffffffffffffffffffffffffffffffffff, sload(3)))
stop()
}
// armazenar novo valor de slot3
sstore(3, newSlot3Value)
}
}
A primeira coisa que fazemos é carregar o calldata na memória e definir o movimento para uma variável de pilha. Em seguida, também armazenamos os slots 2 e 3 como variáveis de pilha. A próxima série de declarações if
são nossas declarações require()
em Solidity. O primeiro é verificar se o jogo está bloqueado. Fazemos isso deslocando o slot 3 em 22 bytes e mascarando um byte. Fazemos um processo semelhante para verificar se um jogo já está em andamento. A próxima verificação é se o jogador enviou ether suficiente para o contrato. As duas últimas verificações checam se o movimento é um movimento adequado que podemos fazer. Agora que sabemos que o chamador seguiu as regras do jogo, precisamos obter os movimentos do jogador. Para player1Move
só precisamos mudar o endereço do jogador 1, deslocando 20 bytes para a direita. Para player2Move
começamos com a mesma operação, mas depois precisamos usar um and()
com uma máscara. Precisamos dessa operação extra porque slot3 empacota variáveis extras no slot. Em seguida, obtemos os endereços dos dois jogadores usando uma máscara e and()
.
Agora precisamos ver qual jogador está cobrando o contrato. Primeiro, verificamos se o Jogador 1 está tentando fazer sua jogada. Se o chamador for o Jogador 1, precisamos verificar se ele ainda não fez uma jogada. Se tiver feito, nós revertemos. Caso contrário, precisamos formatar nosso movimento para que esteja no local adequado da série de bytes que estamos prestes a armazenar. Em seguida, armazenamos nossa jogada no slot 2 e verificamos se o jogador 2 já fez sua jogada. Se tiver feito, precisamos bloquear nosso contrato de reentrada. Então nós chamamos evaluateGame()
, sobre o qual falaremos daqui a pouco. Depois evaluateGame()
corridas, desbloqueamos nosso contrato e usamos um stop()
para terminar a execução da chamada de função. Se o jogador 2 foi quem pagou o contrato, verificamos novamente se ele ainda não fez uma jogada e formatamos nossa variável move
. A fim de evitar um sload()
, desnecessário, armazenamos nosso novo valor para o slot 3 como uma variável de pilha. Depois de verificarmos se o Jogador 1 fez sua jogada, usamos um or()
com o valor para bloquear o jogo e newSlot3Value
, para armazenar esse valor no slot 3. Novamente, chamamos evaluateGame()
, desbloqueie o contrato e interrompa a execução. No entanto, se o Jogador 1 ainda não fez nenhum movimento, em vez disso, armazenamos newSlot3Value
para o slot 3 sem bloquear o contrato.
Antes de olharmos para evaluateGame()
, vamos primeiro olhar para o nosso último caso para a declaração switch
, terminateGame()
.
// terminateGame()
case 0x97661f31 {
let slot3 := sload(3)
let slot2 := sload(2)
// obtém gameStart e gameLength do armazenamento
let gameStart := sload(0)
let gameLength := shr(mul(23,8), slot3)
// verifica se o número do bloco é maior que gameStart + gameLength, senão reverte
if iszero( gt( number(), add(gameStart, gameLength)) ) {
revert(0,0)
}
// obtém gameInProgress do armazenamento
let gameInProgress := and(0xff, shr( mul( 21, 8), slot3 ) )
// se o jogo em andamento não for definido, reverta
if iszero(gameInProgress) {
revert(0,0)
}
// carrega o movimento do jogador 1, se o movimento for feito, então transfere o ether do jogador 1 de volta
let player1Move := shr( mul(20, 8), slot2 )
if iszero( eq(player1Move, 0) ) {
let player1 := and(0xffffffffffffffffffffffffffffffffffffffff , slot2)
pop( call(gas(), player1, selfbalance(), 0, 0, 0, 0) )
resetGame()
stop()
}
// carrega o movimento do jogador 2, se o movimento for feito, então transfere o ether do jogador 2 de volta
let player2Move := and(0xff, shr( mul(20, 8), slot3 ) )
if iszero( eq(player1Move, 0) ) {
let player2 := and(0xffffffffffffffffffffffffffffffffffffffff , slot3)
pop( call(gas(), player2, selfbalance(), 0, 0, 0, 0) )
resetGame()
stop()
}
resetGame()
}
default {
revert(0,0)
}
A primeira etapa é carregar os slots 2 e 3 para empilhar as variáveis novamente. Então nós pegamos gameStart
ao carregar o slot 0, e obtemos gameLength
ao mudar slot3
para a direita por 23 bytes para isolar nossa variável. O primeiro if
está verificando se o bloco atual é maior que a soma de gameStart
e gameLength
. Se não for, nós revertemos. Caso contrário, passamos a verificar se um jogo está em andamento exatamente como fizemos em playGame()
. Agora precisamos verificar se algum jogador já fez uma jogada. Começamos com o Jogador 1 e, se o Jogador 1 fez uma jogada, enviamos a eles os contratos ether com uma chamada vazia para o endereço e passando selfbalance()
como o msg.value
. Nós então chamamos resetGame()
, que examinaremos mais tarde e interromperemos a execução. Se o jogador 1 não fez nenhum movimento, realizamos a mesma operação para o jogador 2. Se nenhum dos jogadores fez um movimento, simplesmente chamamos resetGame()
. O último caso é o padrão. Escolhemos reverter porque isso significa que alguém chamou nosso contrato sem usar um seletor de função adequado.
Ok, finalmente estamos prontos para repassar evaluateGame()
.
// avaliar a função jogar
function evaluateGame() {
let slot2 := sload(2)
let slot3 := sload(3)
// obtém o movimento do player 1 e do player 2 no armazenamento
let player1Move := shr( mul(20, 8), slot2 )
let player2Move := and(0xff, shr( mul(20, 8), slot3 ) )
let player1 := and(0x000000000000000000000000ffffffffffffffffffffffffffffffffffffffff, slot2 )
let player2 := and(0x000000000000000000000000ffffffffffffffffffffffffffffffffffffffff, slot3 )
// Se houver empate os jogadores dividem o pote
if eq(player1Move, player2Move) {
pop( call(gas(), player1, div(selfbalance(), 2), 0, 0, 0, 0) )
pop( call(gas(), player2, selfbalance(), 0, 0, 0, 0) )
resetGame()
leave
}
// se jogador1 move 1 e jogador2 move 3, jogador1 ganha, senão jogador2 ganha
if eq(player1Move, 1) {
if eq(player2Move, 3) {
pop( call(gas(), player1, selfbalance(), 0, 0, 0, 0) )
resetGame()
leave
}
pop( call(gas(), player2, selfbalance(), 0, 0, 0, 0) )
resetGame()
leave
}
// se jogador1 move 2 e jogador2 move 1, jogador1 ganha, senão jogador2 ganha
if eq(player1Move, 2) {
if eq(player2Move, 1) {
pop( call(gas(), player1, selfbalance(), 0, 0, 0, 0) )
resetGame()
leave
}
pop( call(gas(), player2, selfbalance(), 0, 0, 0, 0) )
resetGame()
leave
}
// se jogador1 move 3 e jogador2 move 2, jogador1 ganha, senão jogador2 ganha
if eq(player1Move, 3) {
if eq(player2Move, 2) {
pop( call(gas(), player1, selfbalance(), 0, 0, 0, 0) )
resetGame()
leave
}
pop( call(gas(), player2, selfbalance(), 0, 0, 0, 0) )
resetGame()
leave
}
}
Observe como temos que declarar uma função. Somente nós podemos usar essa função dentro do nosso contrato inteligente. A primeira coisa que a função faz é carregar nossos slots de armazenamento na pilha novamente. Então nós pegamos player1Move
, player2Move
, player1
, e player2
como temos nas funções anteriores. Em seguida, verificamos se o jogo empatou. Se sim, dividimos o ether entre os dois jogadores. Caso contrário, fazemos uma série de verificações para ver quem ganhou. Como as verificações são muito semelhantes, examinaremos a estrutura delas para que você entenda o conceito. O que estamos fazendo é verificar o movimento do Jogador 1. Em seguida, verificamos se o jogador 2 fez a jogada que o faria perder. Se tiverem, enviamos o ether para o Jogador 1, reiniciamos o jogo e deixamos a função. Caso contrário, enviamos o ether para o Player 2, reiniciamos o jogo e deixamos a função.
Nossa última seção do contrato de Yul é a função resetGame()
.
// redefine as variáveis para que um novo jogo possa ser jogado
function resetGame() {
sstore(0,0)
sstore(2, 0)
let gameLengthShifted := shr(mul(23,8), sload(3))
let gameLengthVal := shl(mul(23,8), gameLengthShifted)
sstore(3, gameLengthVal)
}
Parece o mesmo que redefinimos o jogo no contrato híbrido, então nada deve surpreendê-lo aqui.
Isso encerra nossa seção sobre o Contrato de Yul!
Conclusão
Agora que sabemos como escrever um contrato inteligente com a Yul, vamos ver se isso realmente nos economiza gás.
Agora vamos comparar esses números com nossos contratos Solidity e Hybrid!
Uau! Como você pode ver, ao escrever um contrato inteligente puramente em Yul, você pode economizar uma grande quantia em custos de gás para você e seus usuários!
Isso encerra nosso tutorial! Escrever contratos inteligentes em Yul é um tópico avançado e, se você chegou ao fim, parabéns por escrever seu primeiro contrato inteligente inteiramente em Yul! Espero que isso tenha ajudado a entender melhor como o Yul e a EVM funcionam.
Para obter mais informações sobre Opcodes e seus custos de gás, confira estes recursos: \
Códigos de operação: https://ethereum.org/en/developers/docs/evm/opcodes/ \
Dicas EVM: https://github.com/wolflo/evm-opcodes/blob/main/gas.md#a6-sload \
Reembolsos de gás: https://eips.ethereum.org/EIPS/eip-3529
Se você tiver alguma dúvida ou quiser que eu faça um tutorial sobre um tópico diferente, deixe um comentário abaixo.
Se você gostaria de me apoiar fazendo tutoriais, aqui está meu endereço Ethereum: 0xD5FC495fC6C0FF327c1E4e3Bccc4B5987e256794.
Este artigo foi escrito por Marq e traduzido por Diogo Jorge. O artigo original pode ser acessado aqui.
Top comments (0)