Recentemente escrevi um tutorial onde ensinei a primeira parte da construção de um bot cripto para a dex (exchange decentralizada) SushiSwap V3. Na primeira parte eu ensinei como preparar o ambiente de desenvolvimento, como estruturar o projeto e a fazer o monitoramento do preço.
Nesta segunda parte a minha missão é lhe ajudar a implementar a compra e venda (swap) de tokens.
Então vamos lá!
#1 – Conectando na Blockchain
O primeiro passo é obter uma conexão do seu bot com a sua carteira de criptomoedas, sendo que neste exemplo estou usando a MetaMask. Quando você criou a sua MetaMask (o que fizemos no passo anterior) você recebeu um endereço público e por dentro da carteira (em Detalhes da Conta, imagem abaixo) você pegou sua chave privada, certo? Estas duas informações devem estar agora no seu arquivo .env, sob as variáveis WALLET e PRIVATE_KEY. Também incluímos no .env uma variável INFURA_API_KEY e NETWORK, que são configurações para o full node de blockchain que vamos nos conectar.
Resumindo: usaremos o full node para conexão na blockchain e a carteira para realizar as transações.
Para que a comunicação com os contratos inteligentes através da carteira cripto ocorra com sucesso, precisamos da especificação formal deles carregada em nosso sistema, chamada de ABI (Application Binary Interface). Precisamos especificamente de dois ABIs: do ABI do swap router (que falei sobre nesse post aqui) e do ABI genérico de token ERC-20 (que falei extensivamente aqui), para que possamos nos comunicar com o contrato de roteamento da dex e dos tokens, respectivamente. Esses ABIs podem ser facilmente obtidos no block explorer da rede que estiver utilizando, no meu caso peguei os meus na sepolia.etherscan.io buscando pelo endereço dos contratos (os mesmos que coloquei no .env) e indo na aba contract, onde ficam os códigos do contrato, como na imagem abaixo.
O ABI dos tokens ERC-20 está abaixo e você pode copiar livremente, pois ele é igual em todas redes compatíveis com EVM. Crie um arquivo abi.erc20.json na raiz do seu projeto e cole esse conteúdo nele.
1 2 3 |
[{"constant":true,"inputs":[],"name":"name","outputs":[{"name":"","type":"string"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":false,"inputs":[{"name":"guy","type":"address"},{"name":"wad","type":"uint256"}],"name":"approve","outputs":[{"name":"","type":"bool"}],"payable":false,"stateMutability":"nonpayable","type":"function"},{"constant":true,"inputs":[],"name":"totalSupply","outputs":[{"name":"","type":"uint256"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":false,"inputs":[{"name":"src","type":"address"},{"name":"dst","type":"address"},{"name":"wad","type":"uint256"}],"name":"transferFrom","outputs":[{"name":"","type":"bool"}],"payable":false,"stateMutability":"nonpayable","type":"function"},{"constant":false,"inputs":[{"name":"wad","type":"uint256"}],"name":"withdraw","outputs":[],"payable":false,"stateMutability":"nonpayable","type":"function"},{"constant":true,"inputs":[],"name":"decimals","outputs":[{"name":"","type":"uint8"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":true,"inputs":[{"name":"","type":"address"}],"name":"balanceOf","outputs":[{"name":"","type":"uint256"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":true,"inputs":[],"name":"symbol","outputs":[{"name":"","type":"string"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":false,"inputs":[{"name":"dst","type":"address"},{"name":"wad","type":"uint256"}],"name":"transfer","outputs":[{"name":"","type":"bool"}],"payable":false,"stateMutability":"nonpayable","type":"function"},{"constant":false,"inputs":[],"name":"deposit","outputs":[],"payable":true,"stateMutability":"payable","type":"function"},{"constant":true,"inputs":[{"name":"","type":"address"},{"name":"","type":"address"}],"name":"allowance","outputs":[{"name":"","type":"uint256"}],"payable":false,"stateMutability":"view","type":"function"},{"payable":true,"stateMutability":"payable","type":"fallback"},{"anonymous":false,"inputs":[{"indexed":true,"name":"src","type":"address"},{"indexed":true,"name":"guy","type":"address"},{"indexed":false,"name":"wad","type":"uint256"}],"name":"Approval","type":"event"},{"anonymous":false,"inputs":[{"indexed":true,"name":"src","type":"address"},{"indexed":true,"name":"dst","type":"address"},{"indexed":false,"name":"wad","type":"uint256"}],"name":"Transfer","type":"event"},{"anonymous":false,"inputs":[{"indexed":true,"name":"dst","type":"address"},{"indexed":false,"name":"wad","type":"uint256"}],"name":"Deposit","type":"event"},{"anonymous":false,"inputs":[{"indexed":true,"name":"src","type":"address"},{"indexed":false,"name":"wad","type":"uint256"}],"name":"Withdrawal","type":"event"}] |
Depois, crie um segundo arquivo na raiz do projeto chamado abi.router.json e dentro dele cole o ABI do swap router da SushiSwap V3. Acredito que em todas redes ele seja igual, então acho que pode copiar o meu sem problema, que peguei na rede Sepolia.
1 2 3 |
[{"inputs":[{"internalType":"address","name":"_factory","type":"address"},{"internalType":"address","name":"_WETH9","type":"address"}],"stateMutability":"nonpayable","type":"constructor"},{"inputs":[],"name":"WETH9","outputs":[{"internalType":"address","name":"","type":"address"}],"stateMutability":"view","type":"function"},{"inputs":[{"components":[{"internalType":"bytes","name":"path","type":"bytes"},{"internalType":"address","name":"recipient","type":"address"},{"internalType":"uint256","name":"deadline","type":"uint256"},{"internalType":"uint256","name":"amountIn","type":"uint256"},{"internalType":"uint256","name":"amountOutMinimum","type":"uint256"}],"internalType":"struct ISwapRouter.ExactInputParams","name":"params","type":"tuple"}],"name":"exactInput","outputs":[{"internalType":"uint256","name":"amountOut","type":"uint256"}],"stateMutability":"payable","type":"function"},{"inputs":[{"components":[{"internalType":"address","name":"tokenIn","type":"address"},{"internalType":"address","name":"tokenOut","type":"address"},{"internalType":"uint24","name":"fee","type":"uint24"},{"internalType":"address","name":"recipient","type":"address"},{"internalType":"uint256","name":"deadline","type":"uint256"},{"internalType":"uint256","name":"amountIn","type":"uint256"},{"internalType":"uint256","name":"amountOutMinimum","type":"uint256"},{"internalType":"uint160","name":"sqrtPriceLimitX96","type":"uint160"}],"internalType":"struct ISwapRouter.ExactInputSingleParams","name":"params","type":"tuple"}],"name":"exactInputSingle","outputs":[{"internalType":"uint256","name":"amountOut","type":"uint256"}],"stateMutability":"payable","type":"function"},{"inputs":[{"components":[{"internalType":"bytes","name":"path","type":"bytes"},{"internalType":"address","name":"recipient","type":"address"},{"internalType":"uint256","name":"deadline","type":"uint256"},{"internalType":"uint256","name":"amountOut","type":"uint256"},{"internalType":"uint256","name":"amountInMaximum","type":"uint256"}],"internalType":"struct ISwapRouter.ExactOutputParams","name":"params","type":"tuple"}],"name":"exactOutput","outputs":[{"internalType":"uint256","name":"amountIn","type":"uint256"}],"stateMutability":"payable","type":"function"},{"inputs":[{"components":[{"internalType":"address","name":"tokenIn","type":"address"},{"internalType":"address","name":"tokenOut","type":"address"},{"internalType":"uint24","name":"fee","type":"uint24"},{"internalType":"address","name":"recipient","type":"address"},{"internalType":"uint256","name":"deadline","type":"uint256"},{"internalType":"uint256","name":"amountOut","type":"uint256"},{"internalType":"uint256","name":"amountInMaximum","type":"uint256"},{"internalType":"uint160","name":"sqrtPriceLimitX96","type":"uint160"}],"internalType":"struct ISwapRouter.ExactOutputSingleParams","name":"params","type":"tuple"}],"name":"exactOutputSingle","outputs":[{"internalType":"uint256","name":"amountIn","type":"uint256"}],"stateMutability":"payable","type":"function"},{"inputs":[],"name":"factory","outputs":[{"internalType":"address","name":"","type":"address"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"bytes[]","name":"data","type":"bytes[]"}],"name":"multicall","outputs":[{"internalType":"bytes[]","name":"results","type":"bytes[]"}],"stateMutability":"payable","type":"function"},{"inputs":[],"name":"refundETH","outputs":[],"stateMutability":"payable","type":"function"},{"inputs":[{"internalType":"address","name":"token","type":"address"},{"internalType":"uint256","name":"value","type":"uint256"},{"internalType":"uint256","name":"deadline","type":"uint256"},{"internalType":"uint8","name":"v","type":"uint8"},{"internalType":"bytes32","name":"r","type":"bytes32"},{"internalType":"bytes32","name":"s","type":"bytes32"}],"name":"selfPermit","outputs":[],"stateMutability":"payable","type":"function"},{"inputs":[{"internalType":"address","name":"token","type":"address"},{"internalType":"uint256","name":"nonce","type":"uint256"},{"internalType":"uint256","name":"expiry","type":"uint256"},{"internalType":"uint8","name":"v","type":"uint8"},{"internalType":"bytes32","name":"r","type":"bytes32"},{"internalType":"bytes32","name":"s","type":"bytes32"}],"name":"selfPermitAllowed","outputs":[],"stateMutability":"payable","type":"function"},{"inputs":[{"internalType":"address","name":"token","type":"address"},{"internalType":"uint256","name":"nonce","type":"uint256"},{"internalType":"uint256","name":"expiry","type":"uint256"},{"internalType":"uint8","name":"v","type":"uint8"},{"internalType":"bytes32","name":"r","type":"bytes32"},{"internalType":"bytes32","name":"s","type":"bytes32"}],"name":"selfPermitAllowedIfNecessary","outputs":[],"stateMutability":"payable","type":"function"},{"inputs":[{"internalType":"address","name":"token","type":"address"},{"internalType":"uint256","name":"value","type":"uint256"},{"internalType":"uint256","name":"deadline","type":"uint256"},{"internalType":"uint8","name":"v","type":"uint8"},{"internalType":"bytes32","name":"r","type":"bytes32"},{"internalType":"bytes32","name":"s","type":"bytes32"}],"name":"selfPermitIfNecessary","outputs":[],"stateMutability":"payable","type":"function"},{"inputs":[{"internalType":"address","name":"token","type":"address"},{"internalType":"uint256","name":"amountMinimum","type":"uint256"},{"internalType":"address","name":"recipient","type":"address"}],"name":"sweepToken","outputs":[],"stateMutability":"payable","type":"function"},{"inputs":[{"internalType":"address","name":"token","type":"address"},{"internalType":"uint256","name":"amountMinimum","type":"uint256"},{"internalType":"address","name":"recipient","type":"address"},{"internalType":"uint256","name":"feeBips","type":"uint256"},{"internalType":"address","name":"feeRecipient","type":"address"}],"name":"sweepTokenWithFee","outputs":[],"stateMutability":"payable","type":"function"},{"inputs":[{"internalType":"int256","name":"amount0Delta","type":"int256"},{"internalType":"int256","name":"amount1Delta","type":"int256"},{"internalType":"bytes","name":"_data","type":"bytes"}],"name":"uniswapV3SwapCallback","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"uint256","name":"amountMinimum","type":"uint256"},{"internalType":"address","name":"recipient","type":"address"}],"name":"unwrapWETH9","outputs":[],"stateMutability":"payable","type":"function"},{"inputs":[{"internalType":"uint256","name":"amountMinimum","type":"uint256"},{"internalType":"address","name":"recipient","type":"address"},{"internalType":"uint256","name":"feeBips","type":"uint256"},{"internalType":"address","name":"feeRecipient","type":"address"}],"name":"unwrapWETH9WithFee","outputs":[],"stateMutability":"payable","type":"function"},{"stateMutability":"payable","type":"receive"}] |
Agora voltando ao index.js, vamos carregar estes dois ABIs logo abaixo das variáveis que tínhamos definido anteriormente e antes das funções, eles serão necessários nos códigos logo à frente.
1 2 3 4 |
const ABI_ROUTER = require("./abi.router.json"); const ABI_ERC20 = require("./abi.erc20.json"); |
Com estes dois arquivos JSON carregados mais todas as variáveis e configs anteriores à eles, podemos fazer a instanciação dos objetos de full node, de carteira e de contratos, como abaixo.
1 2 3 4 5 6 7 |
const provider = new ethers.InfuraProvider(process.env.NETWORK, process.env.INFURA_API_KEY); const signer = new ethers.Wallet(process.env.PRIVATE_KEY, provider); const router = new ethers.Contract(ROUTER_ADDRESS, ABI_ROUTER, signer); const token0 = new ethers.Contract(TOKEN0, ABI_ERC20, signer); const token1 = new ethers.Contract(TOKEN1, ABI_ERC20, signer); |
O primeiro objeto é a conexão com o full node (provider), que aqui estamos usando um nó Infura para Sepolia, com a API Key e Network definidas no .env.
O segundo objeto (signer) é a config da nossa carteira, que será usada para assinar as transações. Ela é inicializada com nossa chave privada (definida no .env) e o provider recém configurado.
O terceiro objeto é o contrato swap router da SushiSwap V3, inicializado com o endereço do contrato, com o ABI do contrato e com a carteira já configurada.
Já os dois últimos objetos são praticamente iguais, pois são objetos de contrato de token, mas cada um está apontado para um token diferente, pois ora precisaremos de um, ora de outro. Mas ambos usarão a mesma carteira e o mesmo ABI nas transações.
Agora estamos com tudo configurado para comunicação com a blockchain e podemos iniciar a implementação da lógica do robô.
#2 – Fazendo Swap de Tokens
Agora que já sabemos como fazer a conexão no full node com controle total da nossa carteira cripto, é hora de implementarmos a primeira etapa da função que faz o swap dos tokens. É importante salientar que todas transações na blockchain exigem o pagamento da taxa de gás, que deve ser paga sempre na moeda local, ETH no caso da Sepolia. Ou seja, apesar de não estar negociando ETHs neste bot, você deve ter ETH na carteira a fim de pagar as taxas, ok?
Volte ao seu arquivo index.js e na função executeCycle, vamos implementar uma lógica simples de compra e venda.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
async function executeCycle() { const usdPrice = await getPrice(process.env.POOL_ID); console.log("USD " + usdPrice); if (usdPrice < PRICE_TO_BUY && !isOpened) { isOpened = true; console.log("Swap de compra"); } else if (isOpened && usdPrice > PRICE_TO_SELL) { isOpened = false; console.log("Swap de venda"); isApproved = false; } } |
Com essa lógica acima, assim que o preço cair abaixo do parâmetro de gatilho e se o robô ainda não estiver com sua posição aberta (isOpened false) nós vamos fazer a compra acontecer, já sinalizando que a posição ficou aberta (isOpened true). Por outro lado, se a posição já estiver aberta (isOpened true) e o preço de venda foi superado, então a gente finge que vende, reiniciando a variável de controle para que seja possível o robô entrar em outro ciclo de compra.
Entendida essa lógica, e inclusive recomendo que você teste novamente o robô agora, para ver se ele vai se comportar conforme configurado e programado, é hora de fazermos o código para swap. Para isso, vamos criar uma nova função, no mesmo arquivo.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
async function swap(tokenIn, tokenOut, amountIn) { console.log("Building params..."); const params = { tokenIn, tokenOut, fee: 3000,//poolFee = 0.3% * 10000 recipient: WALLET, deadline: Math.ceil((Date.now()/1000)) + 10, amountIn, amountOutMinimum: 0, sqrtPriceLimitX96: 0 } const tx = await router.exactInputSingle(params, { from: WALLET, gasPrice: ethers.parseUnits('10', 'gwei'), gasLimit: 250000 }); console.log("Swapping at " + tx.hash); const receipt = await tx.wait(); const amountOut = ethers.toBigInt(receipt.logs[0].data); console.log("Received " + ethers.formatUnits(amountOut, "ether")); return amountOut; } |
A função swap é possivelmente a mais complexa até aqui. Nela, começamos definindo os parâmetros do swap, a saber:
- tokenIn: endereço do token ERC-20 que você vai dar no swap;
- tokenOut: endereço do token ERC-20 que você quer receber no swap;
- fee: a taxa a ser paga (da SushiSwap, varia de acordo com o liquidity pool que está negociando);
- recipient: quem vai receber os tokens;
- deadline: prazo para conclusão do swap (na blockchain o timestamp é em segundos, por isso o cálculo);
- amountIn: quantidade de tokens que você vai dar no swap (tokenIn, em wei);
- amountOutMinimum: o mínimo de tokenOut que você aceita receber (zero quer dizer qualquer quantia);
- sqrtPriceLimitX96: um parâmetro mais avançado para limitar o preço a ser pago pela moeda (zero quer dizer qualquer preço);
A maioria desses parâmetros é autoexplicativo, mas você encontra a referência técnica completa no site da Uniswap (lembrando que a Sushi é um fork da Uni). Com estes parâmetros, podemos chamar a função do objeto router (que está configurado para o contrato de swap routing) de nome exactInputSingle, que serve para fazer um swap simples/direto. Além dos parâmetros precisamos passar as configurações da transação com quem vai fazê-la (WALLET), qual o preço do gás a ser pago (10 gwei) e qual o limite de gás que estamos dispostos a pagar. Essas configurações adicionais são necessárias porque a lib Ethers não consegue calcular o custo de gás sozinha nos contratos da SushiSwap, possivelmente por causa da arquitetura complexa do projeto, explicada aqui (docs da Uni, mas a arquitetura é igual).
Com a transação enviada, a gente aguarda a sua conclusão (tx.wait) e depois pega nos logs do recibo a quantidade de TOKEN0 recebido no swap, para que possamos usar essa informação depois na venda.
A nossa função de swap está pronta para uso, mas calma, porque se usar ela assim, vai ter um erro de autorização, então precisamos escrever mais um pouco de código.
#3 – Aprovando a transferência
Experimente adicionar chamadas à função de swap no if de compra e de venda do executeCycle. Isso fará com que a transação seja corretamente gerada e enviada para a blockchain. Inclusive você vai receber um recibo da transação como retorno, como abaixo.
No entanto, você vai verificar que nada vai acontecer no saldo das moedas da sua carteira. Então experimente pegar o valor do campo hash e colocar na Sepolia Etherscan e verá um erro que indica que internamente o contrato da DEX tentou fazer uma operação transferFrom, nativa dos tokens ERC20 mas falhou.
Mas por que falhou, se o código estava todo correto?
Isso porque a função transferFrom, conforme especificação ERC-20, permite que um contrato faça transferência de fundos de uma conta para outra, no entanto isso requer uma aprovação prévia (allowance) do dono da carteira que terá seus fundos transferidos delegando essa permissão para o contrato que fará a transferência, a DEX neste caso.
Resumindo: a DEX não pode sair transferindo seus tokens sem a sua aprovação prévia e é isso o que aconteceu aqui. Tentamos fazer a transferência, mas mesmo com a chave privada em mãos, sem autorização prévia e específica para o contrato da DEX, não rola.
Para dar esta permissão vamos criar mais uma função em nosso index.js, que vou chamar de approve, já que este é o nome presente na especificação. Se olharmos olharmos a mesma veremos que a função approve do smart contract deve ter a seguinte assinatura (em Solidity).
1 2 3 |
function approve(address _spender, uint256 _value) public returns (bool success) |
O primeiro parâmetro é o endereço do contrato que vamos dar a permissão e o segundo parâmetro é a quantidade que vamos autorizar deste token. Esta função approve está presente em todos os contratos de token, e devemos chamá-la sempre antes de um transferFrom onde vamos gastar/enviar tokens daquele contrato em questão.
Assim, uma versão da função approve no index.js pode ser vista abaixo.
1 2 3 4 5 6 7 |
async function approve(tokenContract, amount) { const tx = await tokenContract.approve(ROUTER_ADDRESS, amount); console.log("Approving at " + tx.hash); await tx.wait(); } |
Recebemos por parâmetro o objeto do contrato do token e usamos ele para chamar a função approve, passando como primeiro parâmetro quem poderá gastar nosso token (o router da dex) e qual a quantia, aguardando até o final antes de avançar.
Com esta função pronta, agora voltamos ao executeCycle e vamos inserir a lógica de approve sempre antes de um swap.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
async function executeCycle() { const usdPrice = await getPrice(process.env.POOL_ID); console.log("USD " + usdPrice); if (!isApproved) { await approve(token1, AMOUNT_TO_BUY);//approving buy isApproved = true; } if (usdPrice < PRICE_TO_BUY && !isOpened) { isOpened = true; amountOut = await swap(TOKEN1, TOKEN0, AMOUNT_TO_BUY); await approve(token0, amountOut);//approving sell } else if (isOpened && usdPrice > PRICE_TO_SELL) { isOpened = false; await swap(TOKEN0, TOKEN1, amountOut); amountOut = 0; isApproved = false; } } |
Repare como programei a função approve e swap de forma que eu consigo usar elas tanto na compra quanto na venda, apenas mudando os endereços dos contratos. Na compra aprovamos o gasto de TOKEN1 e trocamos ele por TOKEN0. Já na venda aprovamos o gasto de TOKEN0 e trocamos ele por TOKEN1.
Não apenas isso, mas também usei da variável de controle isApproved para determinar se já fizemos a aprovação inicial (para compra), pois assim conseguimos deixar pré-aprovado antes da primeira compra acontecer, o que nos trará agilidade quando isso for necessário. O mesmo vale para a aprovação de venda, que eu já deixo pré-aprovada para a dex assim que compro as moedas, a fim de ganhar agilidade na venda.
Claro que por segurança você pode só aprovar imediatamente antes de fazer a transferência de fato, mas neste caso saiba que corre o risco de demorar demais a sua compra e venda, pois serão duas transações para concluir a negociação ao invés de uma.
Dica Bônus: uma dica para que consiga testar melhor o bot é rodar um fork da Ethereum Mainnet na sua máquina. Você pode simular isso facilmente usando a HardHat Network que ensinei como neste tutorial. Uma vez com ela forkando a Mainnet, você terá muito ETH para os testes (10k), bastando converter parte deles para WETH, o que pode ser feito usando a função deposit do contrato WETH (não esqueça de copiar o ABI do contrato WETH pois o ERC20 padrão não tem função deposit). Assim, com WETH e um fork da ETH Mainnet, você conseguirá testar o swap em cima de qualquer pool pareado com WETH.
E com isso finalizamos o nosso bot trader de criptomoedas na dex SushiSwap V3. Quer aprender outro, desta vez para UniSwap? Confira este tutorial. Ou então para PancakeSwap neste outro.
Olá, tudo bem?
O que você achou deste conteúdo? Conte nos comentários.