Trading: habilitando a compra e venda pública de tokens ERC-20

Lucas Vieira
Block3 Research
Published in
14 min readJul 20, 2022

I. Introdução

Nos últimos artigos explicamos os mais diversos protocolos usados no desenvolvimento de contratos inteligentes utilizando Solidity (como nossa linguagem de programação), a EVM (para compilar e rodar esses contratos) e a rede Ethereum (como nossa rede de blockchain). Desenvolvemos do zero um contrato seguindo o protocolo ERC-20 de tokens fungíveis e posteriormente adicionamos a funcionalidade de Staking nesse contrato. Agora, vamos seguir trabalhando nesse mesmo projeto, dando aos usuários e proprietários dos tokens que criamos a possibilidade de emitir ordens de venda parcial ou para a totalidade de seus tokens. Cada usuário (vendedor) vai poder definir o preço por token na ordem emitida e outros usuários (comprador) poderão observar as ordens listadas e efetuar a compra dos tokens listados. Dessa forma, cria-se assim um mercado público para a compra e venda de tokens fungíveis do nosso contrato e, em consequência, permite que a precificação seja definida livremente pela própria comunidade e seus usuários de maneira descentralizada.

II. Contrato

Vamos começar importando o projeto do contrato ERC-20 com a funcionalidade de staking que foi criado nesse post. Você pode encontrar também o código do projeto nesse repositório do Github. Refrescando um pouco a memória, estamos usando o Hardhat como ambiente de desenvolvimento para o nosso projeto e temos definidos até o momento dois arquivos .sol na nossa pasta contracts/: Token.sol (o contrato principal com as funcionalidades do protocolo ERC-20) e Stakable.sol (contrato herdado pelo contrato principal com funcionalidades relacionadas a lógica de staking).

Agora queremos incluir no contrato principal as lógicas relacionadas a negociação de tokens. Então vamos criar o arquivo Tradable.sol no diretório dos contratos com o esqueleto do nosso novo contrato detentor das funcionalidades e da lógica de compra e venda de tokens.

Antes de começar a botar a mão na massa, vamos pensar juntos nas funcionalidades necessárias para que as negociações funcionem corretamente:

  • Como proprietário de tokens, gostaria de poder emitir uma ordem de venda parcial ou para a totalidade dos meus tokens no preço que eu desejar.
  • Como proprietário de tokens com ordens de venda ativas, gostaria de poder desistir da venda e tornar a ordem inativa.
  • Como proprietário de tokens com ordens de venda ativas, gostaria de poder alterar o preço por token da minha ordem.
  • Como usuário da comunidade, gostaria de poder comprar tokens oriundos de ordem de vendas ativas.
  • Como usuário de comunidade, gostaria de poder listar as ordens emitidas.
  • Como usuário da comunidade, gostaria de saber o preço atual do token (preço relativo a última venda válida).
  • Como proprietário de uma ordem vendida realizada, gostaria de sacar o valor da venda para a minha carteira.
  • Como criador do contrato, gostaria de receber uma taxa em cima de cada venda realizada.

Com essas histórias desenvolvidas abrangemos boa parte da lógica mínima necessária para operar um mercado de compra e venda de tokens de maneira descentralizada! Agora com uma ideia mais clara do que precisamos desenvolver, vamos voltar ao nosso contrato Tradable.sol definindo as variáveis e os eventos necessários.

Definimos uma struct SellOrder para definir o formato das ordens de venda que iremos salvar. Nossas ordens vão ter: um id para identificá-las de maneira única, o endereço do vendedor seller, a quantidade de tokens à venda na ordem amount, o preço de cada token price, e um booleano active para sabermos se a ordem está ativa ou não. Para armazenar as ordens definimos uma lista de SellOrder orders.

struct SellOrder {
uint256 id;
address seller;
uint256 amount;
uint256 price;
bool active;
}
SellOrder[] internal orders;

Além disso, definimos 4 eventos:

  • OrderPlaced que será emitido sempre que uma nova ordem de venda for criada.
  • Selled que será emitido sempre que houver uma venda parcial ou total dos tokens de uma ordem.
  • UpdateOrderPrice que será emitido sempre que for atualizado o preço dos tokens de uma ordem.
  • UpdateOrderAmount que será emitido sempre que uma quantidade parcial dos tokens de uma ordem forem vendidos.
event OrderPlaced(uint256 indexed id, address indexed seller, uint256 amount, uint256 price);event Selled(uint256 indexed id, address indexed seller, address indexed buyer, uint256 amount, uint256 price);event UpdatedOrderPrice(uint256 indexed id, uint256 newPrice);event UpdatedOrderAmount(uint256 indexed id, uint256 newAmount);

Vamos agora começar a desenvolver a lógica do nosso contrato construindo as funções necessárias para satisfazermos as histórias que pontuamos anteriormente, criando assim a funcionalidade de negociação. Vamos começar definindo uma função interna (que será usada pelo contrato principal) _placeOrder que contem a lógica para emissão de uma nova ordem.

A função é bem simples, ela recebe o total de tokens amount e preço por token price da ordem emitida. Checamos primeiro o número de items da nossa lista de ordens orders usando orders.length para checar ao ID da próxima ordem orderId (O próximo ID vai ser igual ao número de items pois a listagem começa com o ID 0). Importante notar, que nesse ponto apenas checamos se o total e o preço são não nulos e positivos. Você deve estar se perguntando: "mas não deveríamos checar se o endereço que está emitindo a ordem tem o total de tokens que deseja vender?". E a resposta é: Sim! Mas lembre-se que a ideia desse contrato é exportar a lógica de negociações para deixar nosso código com responsabilidades bem definidas. O contrato Tradable não tem e nem deveria ter informações sobre os saldos dos usuários. O contrato principal tem e ele que deve fazer essa checagem antes de chamar a função _placeOrder, conforme veremos mais a frente.

Agora vamos incluir nossa função para a remoção de uma ordem ativa _removeOrder que basicamente seta o booleano active da ordem como false. Antes disso, a função deve checar se o endereço que está lhe chamando é a do criador da ordem e se a ordem está ativa, como podemos ver abaixo.

A próxima função que vamos criar, será a que permite o usuário atualizar o preço por token de uma de suas ordens _updateOrderPrice. Assim como na função anterior, checamos se o endereço (que chama a função) é o dono da ordem e se ela está ativa. Além disso, devemos checar se o novo preço fornecido é positivo e não nulo. Em seguida atualizamos a ordem de orderId com o novo preço newPrice e emitimos um evento de UpdatedOrderPrice.

Agora vamos criar a função que permitirá a compra de tokens de ordens de venda ativas _buyOrder. A função deverá receber o id da ordem orderId e total de tokens amount que se deseja comprar. Diferentemente das funções anteriores, checamos se o endereço que chama a função é diferente do endereço do vendedor, para evitar que seja possível comprar uma ordem que você mesmo criou. Além disso, checamos se a ordem está ativa e se o total de tokens a se comprar é menor que o total de tokens disponíveis na ordem de venda. Caso todos esses pontos sejam válidos, diminuímos o total de tokens da ordem do total que se deseja comprar e caso o total final de tokens da ordem seja igual a 0, inativamos a mesma. Podemos ver a seguir em detalhes a função descrita.

Para completar o nosso contrato Tradable com todas as funcionalidades necessárias para a negociação de tokens, vamos criar duas funções de leitura _getOrder e _listOrders que retornam uma ordem específica e todas as ordens criadas respectivamente.

Agora temos o nosso contrato Tradable completo com tudo que precisamos para seguir o desenvolvimento da nossa funcionalidade! Vamos voltar para o nosso contrato principal Token.sol para importar o novo contrato criado e começarmos a desenvolver as funções externas que poderão ser executadas pelos usuários e que usam as funções internas que acabamos de implementar. Fazemos o import e herdamos as funções do nosso novo contrato da seguinte maneira:

Vamos começar o desenvolvimento criando as funções externas de placeOrder (para criar uma nova ordem) e removeOrder (para inativar uma de suas ordens).

A função placeOrder precisa checar se o total de tokens que o endereço deseja depositar na ordem de venda é menor ou igual o seu saldo de tokens. Além disso, note como usamos as funções _mint e _burn, anteriormente definidas no nosso contrato principal, para emitir o total de tokens contidos na ordem quando o usuário a torna inativa e para queimar os tokens do saldo do usuário quando ele cria uma nova ordem, tirando esses tokens de circulação até que seja efetuada uma venda ou a ordem seja removida. Processo similar ao que fizemos na nossa lógica de staking quando o usuário aplicava ou removia o stake e quando fazia o saque de suas recompensas.

Agora chegou a hora de criarmos a função que de fato vai permitir que os usuários façam a compra de tokens presentes numa ordem de venda ativa buyOrder. Diferentemente das funções que definimos até aqui, essa função vai envolver o pagamento de ethers, por parte do endereço que está executando a função, para efetivar a compra dos tokens. Para isso, vamos usar o modificador payable na nossa função. Dessa forma temos acesso ao endereço de quem executou a função com o parâmetro msg.sender e o valor de ethers que o endereço enviou junto a função com o parâmetro msg.value. E assim, checamos se o valor passado é igual ou maior ao preço final do total de tokens que se deseja comprar:

SellOrder memory order = _getOrder((orderId));
uint256 orderPrice = order.price * amount;
require(msg.value >= orderPrice, "Token: ether sent must be greater or equal orderPrice.");

Após checar o valor recebido, vamos então transferir o valor para o endereço que criou a ordem. Porém, não é uma boa prática incluir transferências de ethers na mesma função em que o pagamento é recebido, podendo gerar graves problemas de segurança. Nesse repositório do Github temos uma lista das principais vulnerabilidades que podemos enfrentar ao desenvolver contratos em Solidity. E para implementar nossa função de uma maneira eficaz, vamos usar o Withdraw Pattern. Para isso vamos criar um mapping para armazenar os saldos em ether dos usuários.

mapping (address => uint256) private _etherBalances;

Na função de compra, vamos atualizar o saldo do endereço através dessa variável e criaremos uma nova função withdraw para que os usuários possam sacar o seu saldo de ethers. Um dos pontos que listamos nas nossas funcionalidades foi incluir o pagamento de uma taxa ao dono do contrato sempre que uma venda for efetuada. Fazemos isso da seguinte maneira:

address owner = owner();
uint256 ownerFee = msg.value / 100;
uint256 sellerPayment = msg.value - ownerFee;
_etherBalances[order.seller] += sellerPayment;
_etherBalances[owner] += ownerFee;

Usamos a função owner da biblioteca Ownable para retornar o endereço do dono do contrato e então tiramos 1% do valor enviado na função buyOrder como taxas para o dono do contrato e o restante para o vendedor da ordem. Além disso, vamos criar uma nova variável _currentTokenPrice que vai armazenar o preço por token da última compra de ordem realizada. Assim teríamos uma forma de mapear o valor atual do token do nosso contrato (quanto alguém está disposto a pagar por ele no momento). E os usuários e proprietários do token poderão usar esse valor para definir o preço de suas ordens de venda de acordo com o que a própria comunidade acredita que seja o real valor dele. Se você emitir uma ordem de venda com um preço muito acima do preço efetivado na última transação é provável que sua ordem demore mais a ser comprada. Por outro lado, se você colocar um preço muito abaixo, é provável que sua ordem seja realizada rapidamente.

Claro que num projeto real deveríamos ter uma lógica muito mais complexa e eficiente para acompanhar o preço real e atual do token, mas usamos da licença poética para poder demonstrar essa funcionalidade de maneira simples, para ser mais didático.

Outro ponto para tornarmos essa função mais segura é criar um modificador de acesso para evitar Reentrancy Attacks, quando um usuário mal intencionado chama a função múltiplas vezes antes que a chamada anterior termine de ser executada. Podendo causar mudanças inesperadas em dados sensíveis do contrato, como os saldos de ether. Para isso, criamos um mapping de booleanos _locked, para checar se um endereço está atualmente chamando a função, e um modificador, que retorna um erro ao chamar novamente uma função que ainda não terminou de ser executada.

mapping (address => bool) private _locked;modifier noReentrant() {
require(!_locked[msg.sender], "Token: no reentrancy");
_locked[msg.sender] = true;
_;
_locked[msg.sender] = false;
}

E não podemos nos esquecer de chamar a função _mint para emitir os tokens comprados pelo endereço que executou a função de compra. Com isso, nosso contrato ficará da seguinte forma:

Note alguns pontos na função de withdraw, nós atualizamos os saldos de ethers antes de fazer de fato a transferência e, além disso, nós usamos o método call para transferir o valor que nos retorna um booleano para checarmos se a transferência foi bem sucedida e que é, atualmente, o método mais recomendado para se fazer transferências (temos call, send e transfer).

Agora temos boa parte da nossa funcionalidade pronta! Vamos apenas criar a função para atualizar o preço por token de uma ordem updateOrderPrice, que basicamente chama a função interna do contrato Tradable. Além das funções de leitura para retornar o saldo de ethers getEtherBalance, uma ordem específica getOrder, todas as ordens criadas listOrders e o preço atual do token getCurrentTokenPrice. Para facilitar o processo de testes que faremos a seguir, vamos incluir a criação de uma ordem de venda no construtor do contrato. Assim, logo após o deploy já teremos uma ordem de venda ativa. O endereço criador da ordem será o endereço que chamou o deploy do contrato, o dono. E usamos 10 ** 16, o equivalente a 0.01 ETH em Wei, como o preço por token da ordem e como o preço atual do token _currentTokenPrice. A seguir podemos ver o código final e completo do nosso Token ERC-20 com as funcionalidades de Staking e Trading.

III. Testes

Vamos agora certificar que tudo está funcionando como deveria criando um arquivo de testes test/tokenTrade.js para testar as funcionalidades que desenvolvemos.

Começamos por definir as variáveis que vamos usar ao longo dos testes e um primeiro teste para checar que o contrato tem a formatação correta após ser deployado. Nesse primeiro teste vamos então:

  • Salvar as contas geradas automaticamente pelo hardhat na variável accounts.
  • Fazer o deploy do contrato Token.
  • Guardar a conta de id 0 responsável pelo deploy e dona do contrato na variável owner.
  • Listar as ordens com a função listOrders.
  • Certificar que temos apenas uma ordem, que o total de tokens nela é igual o montante passado nos parâmetros do deploy, que a conta owner é o criador da ordem e que o preço por token e preço atual do token estão corretos.

Vamos rodar o teste criado usando o comando:

npx hardhat test --grep Trade

E podemos ver que o nosso teste passa com sucesso! Como estamos usando a biblioteca hardhat-gas-reporter com a nossa chave de API da CoinMarketCap temos um detalhe do montante de gas utilizado no deploy do contrato e o quanto isso reflete no custo em BRL com as cotações atuais do preço de gas e do ETH.

Vamos seguir nossos testes, incluindo um para testar que é possível comprar tokens da ordem inicial que foi criada no construtor do contrato. Então vamos criar mais um teste que deve executar os seguintes passos:

  • Associar a conta de id 1 a variável lucas.
  • Usar a conta acima para fazer a compra de 10k dos 5M tokens listados na ordem de venda com a função buyOrder.
  • Checar que o total de tokens da ordem foi atualizado para 4,99M tokens.
  • Checar que o saldo do comprador atualizou corretamente para 10k.
  • Checar que o saldo de ethers do dono do contrato, criador da ordem, é igual ao valor em ethers passado na função buyOrder. O dono deve receber 100% já que a taxa de 1% também vai para ele.

Note como usamos um objeto de options com o parâmetro value para passar o montante de ethers ao executarmos a função.

const tx = await token.connect(lucas).buyOrder(order.id, amount, { value: ethers.utils.parseEther(price.toString())});

Além disso, usamos as funções utilitárias da biblioteca ethers formatEther e parseEther para formatar corretamente os valores em ethers que vamos usar em cada etapa. Rodando os testes podemos observar que tudo passa como o esperado e também temos a média de custo da função buyOrder.

Agora vamos testar que é possível criar uma nova ordem com a função placeOrder:

  • Usamos a função para criar duas ordens, uma com 1k tokens e preço de 0.005 ETH por token e a outra com 4k tokens e o preço de 0.1 ETH por token.
  • Usamos a conta lucas para criação das ordens.
  • Checar se ambas as ordens foram criadas corretamente, analisando os seus atributos.

Novamente executamos o nosso comando de testes para certificar que tudo funcionou corretamente. Temos também o custo médio para criação de uma nova ordem.

Agora vamos criar um teste para verificar que o preço atual do token é atualizado ao ser efetuada uma nova compra de uma ordem com preço por token diferente do preço atual. Nesse teste vamos fazer os seguintes passos:

  • Listar todas as ordens com a função listOrders.
  • Criar uma função auxiliar parseOrdersToObj para formatar o valor retornado pela função acima.
  • Associar a conta de id 2 a variável matheus.
  • Organizar as ordens por preço de maneira crescente.
  • Usar a nova conta para comprar metade dos tokens da ordem com preço mais barato.
  • Checar que o saldo de tokens da conta matheus foi atualizado.
  • Checar que o saldo de ethers da conta lucas aumentou em 99% do valor enviado na função de compra, que é o total da compra menos 1% do valor em taxas para o dono do contrato.
  • Checar que o preço atual do token retornado pela função getCurrentTokenPrice mudou para o preço da ordem comprada.

Como de costume rodamos os testes para certificar que tudo funciona como deveria.

No próximo teste checamos se é possível remover uma ordem de venda ativa. Criamos então um teste em que filtramos as ordens emitidas pela conta lucas e usamos função removeOrder para torná-la inativa.

Certificamos que tudo está correto rodando os testes e temos uma média do custo de gas units da função removeOrder.

Agora, vamos incluir os testes para checar a possibilidade do usuário sacar seu saldo em ethers do contrato com a função withdraw. Vamos conectar com a conta lucas que tem um saldo positivo de ethers devido a última compra feita no teste anterior.

Note que incluímos alguns logs para retornar informações sobre o custo de gas em ETH da operação multiplicando o custo do gas no momento da transação tx.gasPrice pela média em gas units da função withdraw. Em seguida batemos os valores com o saldo da conta usando a função getBalance da biblioteca ethers.

const lucasAccountBalanceAfter = await lucas.getBalance();

Ao rodar o comando de teste podemos verificar que tudo passa com sucesso e que os números dos saldos e custos de gas dos logs fazem sentido.

Por fim, vamos incluir agora um teste para checar se conseguimos atualizar o preço de uma ordem de venda ativa com a função updateOrderPrice e incluir também testes para checar os seguintes casos de erro:

  • Criar uma ordem com um total de tokens maior que o endereço possui.
  • Criar uma ordem com total de tokens e/ou preço por token nulos.
  • Remover uma ordem que não é sua.
  • Remover uma ordem inativa.
  • Comprar um total de tokens maior que os listados na ordem.
  • Comprar uma ordem com um valor em ethers menor que o preço.
  • Comprar tokens de uma ordem que você criou.
  • Comprar tokens de uma ordem inativa.
  • Fazer o saque de um endereço com saldo de ethers vazio.

Nosso arquivo final de testes pode ser visto abaixo:

E podemos notar que todos os testes passam corretamente, conforme esperado! :)

Temos agora nosso contrato de um token fungível ERC-20 com funcionalidades de Staking e Trading! :P

Obrigado por ter acompanhado até aqui, se curtiu esse post não esquece de dar um clap e um follow pra acompanhar os próximos! Me ajuda muito! :)

Qualquer dúvida, crítica ou sugestão é sempre bem-vinda!

Você pode acessar o conteúdo completo desse projeto no meu repositório do Github.

E se quiser bater um papo, meu Linkedin.

--

--

Lucas Vieira
Block3 Research

Hi! I'm a brazilian software engineer who love to learn and build new things. My goal here is to share I little about that on the way :) https://bushi.solutions