Como simular vários caminhos de troca DEX usando Solidity/Foundry
Introdução
À medida que você se dedica à construção do seu bot de MEV, você percebe rapidamente que, embora a execução da sua lógica seja crucial, um aspecto frequentemente subestimado do processo é a busca por oportunidades de MEV. Hoje, quero discutir como podemos buscar efetivamente essas oportunidades, que podem ocorrer em duas regiões principais:
- Dados do Mempool: transações pendentes
- Dados de transação/evento: transações confirmadas
Quando se trata de MEV, o termo geralmente traz à mente a análise de dados do mempool, especificamente transações pendentes. Você já deve ter se deparado com conceitos como bots de front-running, back-running e sandwiching que aproveitam as transações pendentes para lucrar com elas. Esses bots vasculham o mempool, que contém transações que ainda não foram incluídas em um bloco, e tentam identificar possíveis transações que possam beneficiá-los de alguma forma.
A ideia por trás desses bots é analisar as transações pendentes no mempool e simular vários cenários para determinar se alguma dessas transações pode ser explorada para obter lucro. Ao examinar cuidadosamente as características dessas transações, como seu conteúdo, taxas de gás e alvos pretendidos, esses bots podem avaliar os possíveis resultados e tomar decisões informadas sobre a possibilidade de prossegui-las.
No entanto, hoje quero falar sobre um método de busca/simulação mais simples que todo trader on-chain deve entender e usar em seu processo de desenvolvimento de estratégia. Esse método usa dados que já foram confirmados na blockchain. Embora as transições de estado tenham ocorrido e finalizado na blockchain, ainda pode haver discrepâncias entre vários protocolos de DEX, e eu gostaria de simular vários caminhos de troca entre esses protocolos para ver se meu caminho é lucrativo ou não.
Por que precisamos de um mecanismo de simulação?
Talvez você esteja se perguntando por que precisamos de um mecanismo de simulação. Se estivermos falando de estratégias extremamente simples que envolvem a interação com um único protocolo de DEX, talvez não seja necessário. Mas estamos falando de várias DEXes em uma variedade de ecossistemas de blockchain. Portanto, sim, para descobrir com precisão se sua negociação será bem-sucedida e para simular o lucro esperado, cada desenvolvedor de bots MEV terá de criar seus próprios mecanismos de simulação.
De fato, dar uma olhada nos repositórios do Github relacionados aos bots MEV lhe dará toda a base de código para bots que podem executar suas negociações usando relés privados como Flashbots, mas o que eles não lhe darão é o mecanismo de busca/simulação.
Abaixo está uma implementação completa de um bot de sanduíche escrito em JS pela libevm:
https://github.com/libevm/subway
Outro é o do Flashbots:
https://github.com/flashbots/simple-arbitrage
Terei a oportunidade de analisar esses códigos em uma postagem posterior do blog, quando abordar o lado da execução dos bots MEV.
Agora entendemos que é importante criar nosso próprio bot de busca e mecanismo de simulação. Mas por onde começar?
- Pesquisar
- Simular
Pesquisar e simular. Ambos são muito importantes, mas exigem um conjunto diferente de conhecimentos, portanto, hoje vou me concentrar apenas na parte do mecanismo de simulação.
Vamos começar a construir agora mesmo
A melhor maneira de começar a aprender é fazendo, especialmente no campo das blockchains - e no trade!
O que estamos construindo hoje?
Quero realizar swaps de vários saltos (uma arbitragem n-way típica, como as arbitragens triangulares) em várias DEXs usando uma única cadeia. Por exemplo, meus swaps poderiam ocorrer no Curve Finance, Uniswap V2, Uniswap V3 e qualquer outro número de DEXes que você queira incluir.
O que estamos simulando?
Quero descobrir se o caminho de n vias que encontrei será lucrativo se eu enviar uma transação realizando as trocas.
Isso pode ser feito de duas maneiras:
- Programando seu próprio simulador que tenha todas as funções de impacto de preço implementadas. (O motivo pelo qual o impacto no preço é fundamental está descrito aqui: https://www.paradigm.xyz/2021/04/understanding-automated-market-makers-part-1-price-impact)
- Usando contratos inteligentes para simular impactos nos preços.
Hoje, vou usar a segunda abordagem, porque a primeira consome muito tempo. Com a primeira abordagem, você terá de entender como seus swaps/negociações afetarão os preços dos pares em diferentes protocolos de DEX, lendo minuciosamente seus documentos e contratos. A parte difícil disso é que as fórmulas AMM que essas DEXes usam são todas diferentes umas das outras.
Mas com a segunda abordagem, você não precisará entender essas fórmulas. Tudo o que você precisa é navegar um pouco e descobrir quais funções as DEXes usam para simular suas trocas. Essas informações geralmente são públicas e podem ser facilmente encontradas com alguma familiaridade com o Solidity. Além disso, chamar as funções de contrato inteligente é gratuito se você não estiver alterando o estado da blockchain - além da taxa de criação do contrato.
Configuração do projeto
Usarei o Foundry para escrever meu contrato inteligente para simular os caminhos de troca potencialmente lucrativos.
O Foundry usa o Rust, portanto, você precisará ter o Rust/Cargo instalado. Execute o comando abaixo para instalar o Foundryup:
curl -L https://foundry.paradigm.xyz | bash
Agora execute:
foundryup
Isso instalará todos os comandos de que você precisa para começar a construir com o Foundry.
Agora que você tem as dependências instaladas, pode inicializar seu projeto Foundry:
forge init swap-simulator-v1
cd swap-simulator-v1 && forge build
forge install OpenZeppelin/openzeppelin-contracts
No diretório src, crie um novo arquivo Solidity chamado "SimulatorV1.sol".
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.9;
import "openzeppelin-contracts/contracts/utils/math/SafeMath.sol";
contract SimulatorV1 {
using SafeMath for uint256;
// Polygon network addresses
address public UNISWAP_V2_FACTORY = 0x5757371414417b8C6CAad45bAeF941aBc7d3Ab32;
address public UNISWAP_V3_QUOTER2 = 0x61fFE014bA17989E743c5F6cB21bF9697530B21e;
struct SwapParams {
uint8 protocol; // 0 (UniswapV2), 1 (UniswapV3), 2 (Curve Finance)
address pool; // used in Curve Finance
address tokenIn;
address tokenOut;
uint24 fee; // only used in Uniswap V3
uint256 amount; // amount in (1 USDC = 1,000,000 / 1 MATIC = 1 * 10 ** 18)
}
constructor() {}
function simulateSwapIn(SwapParams[] memory paramsArray) public returns (uint256) {
}
function simulateUniswapV2SwapIn(SwapParams memory params) public returns (uint256 amountOut) {
}
function simulateUniswapV3SwapIn(SwapParams memory params) public returns (uint256 amountOut) {
}
function simulateCurveSwapIn(SwapParams memory params) public returns (uint256 amountOut) {
}
}
Essa é a estrutura básica do nosso simulador. Usaremos a Polygon, porque a taxa de gas lá é muito barata e, portanto, é um bom lugar para testar seu código.
O código é bastante auto explicativo, pois podemos ver que chamaremos a função "simulateSwapIn" enviando uma matriz de SwapParams, que é uma estrutura.
Agora vamos criar a função:
function simulateSwapIn(SwapParams[] memory paramsArray) public returns (uint256) {
uint256 amountOut = 0;
uint256 paramsArrayLength = paramsArray.length;
for (uint256 i; i < paramsArrayLength;) {
SwapParams memory params = paramsArray[i];
if (amountOut == 0) {
amountOut = params.amount;
} else {
params.amount = amountOut;
}
if (params.protocol == 0) {
amountOut = simulateUniswapV2SwapIn(params);
} else if (params.protocol == 1) {
amountOut = simulateUniswapV3SwapIn(params);
} else if (params.protocol == 2) {
amountOut = simulateCurveSwapIn(params);
}
unchecked {
i++;
}
}
return amountOut;
}
Insira essa definição de função no bloco vazio "simulateSwapIn" acima. Não se preocupe com o que ela faz ainda. Em breve, trataremos disso.
Antes de analisarmos essa função, porém, precisamos entender como as DEXes permitem que você simule suas negociações com funções como:
- getAmountOut (UniswapV2)
- quoteExactInputSingle (UniswapV3)
- get_dy (Curve Finance)
Primeiro, a UniswapV2.
A UniswapV2 é a mais fácil de todas. E como muitas DEXes são bifurcações da UniswapV2, esse método também se aplicará às outras.
Se você acessar aqui:
Você verá uma função como esta:
// dado um valor de entrada de um ativo e um par de reservas, retorna o valor máximo de saída do outro ativo
function getAmountOut(uint amountIn, uint reserveIn, uint reserveOut) internal pure returns (uint amountOut) {
require(amountIn > 0, 'UniswapV2Library: INSUFFICIENT_INPUT_AMOUNT');
require(reserveIn > 0 && reserveOut > 0, 'UniswapV2Library: INSUFFICIENT_LIQUIDITY');
uint amountInWithFee = amountIn.mul(997);
uint numerator = amountInWithFee.mul(reserveOut);
uint denominator = reserveIn.mul(1000).add(amountInWithFee);
amountOut = numerator / denominator;
}
Com essa função, você pode simular a quantidade de tokens que receberá se inserir "amountIn" nesse pool do UniswapV2.
Para usar isso, crie um novo diretório em src chamado protocolos e, em seguida, faça a uniswap nos protocolos. Ele terá a seguinte aparência src/protocols/uniswap:
Já adicionei todos os arquivos do Solidity de que preciso em meu diretório src. Você pode fazer o mesmo. Agora, copie e cole o arquivo UniswapV2Library.sol em seu src/protocols/uniswap/UniswapV2Library.sol. No entanto, há um problema. A versão do compilador Solidity usada para a UniswapV2 não corresponde à dos compiladores mais modernos. Portanto, vá até meu Github e copie e cole o código de lá. Isso deve funcionar.
Este é o repositório:
Depois de criar os arquivos de interfaces/biblioteca no diretório de protocolos, você estará pronto para entender como simulamos as trocas na UniswapV2.
Vamos voltar ao código do SimulatorV1:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.9;
import "openzeppelin-contracts/contracts/utils/math/SafeMath.sol";
// fazer todas as importações
import "./protocols/uniswap/UniswapV2Library.sol";
import "./protocols/uniswap/IQuoterV2.sol";
import "./protocols/curve/ICurvePool.sol";
contract SimulatorV1 {
using SafeMath for uint256;
// Polygon network addresses
address public UNISWAP_V2_FACTORY = 0x5757371414417b8C6CAad45bAeF941aBc7d3Ab32;
address public UNISWAP_V3_QUOTER2 = 0x61fFE014bA17989E743c5F6cB21bF9697530B21e;
struct SwapParams {
uint8 protocol;
address pool;
address tokenIn;
address tokenOut;
uint24 fee;
uint256 amount;
constructor() {}
// outro código aqui
function simulateUniswapV2SwapIn(SwapParams memory params) public returns (uint256 amountOut) {
(uint reserveIn, uint reserveOut) = UniswapV2Library.getReserves(
UNISWAP_V2_FACTORY,
params.tokenIn,
params.tokenOut
);
amountOut = UniswapV2Library.getAmountOut(
params.amount,
reserveIn,
reserveOut
);
}
// outro código aqui
}
Usando a UniswapV2Library, obtemos as reservas do par de troca que consiste em tokenIn e tokenOut. Esses serão endereços como:
- USDC: 0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174 (https://polygonscan.com/address/0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174)
- USDT: 0xc2132D05D31c914a87C6611C10748AEb04B58e8F (https://polygonscan.com/address/0xc2132D05D31c914a87C6611C10748AEb04B58e8F)
Com essas reservas, ela chamará "getAmountOut" e obterá o resultado do swap da negociação "amount". Retornamos esse valor.
Em segundo lugar, a UniswapV3.
A UniswapV3 é um pouco mais complicado, mas não se assuste. As documentações lhe dizem muito. Especialmente aqui:
Usando o Quoter2, você pode usar "quoteExactInputSingle" para simular uma única troca de pools V3. Novamente, para conseguir isso, acesse meu Github e copie e cole IQuoterV2.sol em src/protocols/uniswap/IQuoterV2.sol.
Agora, acesse novamente o arquivo SimulatorV1.sol:
// imports...
contract SimulatorV1 {
// outro código aqui
function simulateUniswapV3SwapIn(SwapParams memory params) public returns (uint256 amountOut) {
IQuoterV2 quoter = IQuoterV2(UNISWAP_V3_QUOTER2);
IQuoterV2.QuoteExactInputSingleParams memory quoterParams;
quoterParams.tokenIn = params.tokenIn;
quoterParams.tokenOut = params.tokenOut;
quoterParams.amountIn = params.amount;
quoterParams.fee = params.fee;
quoterParams.sqrtPriceLimitX96 = 0;
(amountOut,,,) = quoter.quoteExactInputSingle(quoterParams);
}
// outro código aqui
}
Como o QuoterV2 é um contrato que está de fato implantado na rede (como pode ser visto aqui: https://docs.uniswap.org/contracts/v3/reference/deployments), você precisará envolver o endereço do QuoterV2 com uma interface do IQuoterV2 e criar a estrutura de entrada QuoteExactInputSingleParams para chamar a função de destino.
Terceiro, Curve Finance.
O Curve é um pouco mais complicado, pois é muito diferente das outras DEXes do fork Uniswap. Mas esse projeto realmente tem a interface feita para nós:
Copiei e colei a interface do Curve Finance pools daqui. Depois de fazer isso, verifiquei se estava atualizada. E verifiquei que, pelo menos com o 3pool que eu estava interessado em usar do Curve Finance, as interfaces correspondiam:
Depois que isso for configurado, vamos acessar o arquivo SimulatorV1.sol novamente:
// imports...
contract SimulatorV1 {
// outro código aqui
function simulateCurveSwapIn(SwapParams memory params) public returns (uint256 amountOut) {
ICurvePool pool = ICurvePool(params.pool);
int128 i = 0;
int128 j = 0;
int128 coinIdx = 0;
while (i == j) {
address coin = pool.coins(coinIdx);
if (coin == params.tokenIn) {
i = coinIdx;
} else if (coin == params.tokenOut) {
j = coinIdx;
}
if (i != j) {
break;
}
unchecked {
coinIdx++;
}
}
amountOut = ICurvePool(params.pool).get_dy(
i,
j,
params.amount
);
}
}
Isso parece um pouco mais difícil, porque o Curve não armazena informações sobre o token 0, token 1. Isso ocorre porque os pools do Curve podem aceitar mais de 2 tokens como pares. E com o 3pool, há 3 stablecoins no pool. Outros também podem ter mais.
Portanto, executamos um loop while no Solidity e tentamos combinar os tokens com o número de índice usado no contrato desse pool.
Depois de descobrirmos o índice da moeda de nosso tokenIn e tokenOut, chamamos a função "get_dy" para simular o stableswap da Curve Finance. Também retornamos esse valor.
A função simulateSwapIn
Agora podemos entender a função "simulateSwapIn", que veremos novamente:
function simulateSwapIn(SwapParams[] memory paramsArray) public returns (uint256) {
// inicia o valor resultante para 0
uint256 amountOut = 0;
uint256 paramsArrayLength = paramsArray.length;
// percorrer cada um dos valores em paramsArray, um por um
for (uint256 i; i < paramsArrayLength; ) {
SwapParams memory params = paramsArray[i];
// Se nenhum swap tiver sido simulado ainda, defina amountOut como
// o valor inicial em valor da estrutura params
if (amountOut == 0) {
amountOut = params.amount;
} else {
// se amountOut não for 0, o que significa que um caminho de troca foi simulado
// pelo menos uma vez, use essa saída para ser o "valor"
params.amount = amountOut;
}
if (params.protocol == 0) {
amountOut = simulateUniswapV2SwapIn(params);
} else if (params.protocol == 1) {
amountOut = simulateUniswapV3SwapIn(params);
} else if (params.protocol == 2) {
amountOut = simulateCurveSwapIn(params);
}
// Não se preocupe com esta parte
// ele simplesmente incrementa i em 1
// Este código é referenciado em: https://github.com/Uniswap/universal-router/blob/main/contracts/UniversalRouter.sol
unchecked {
i++;
}
}
return amountOut;
}
O código acima faz muito mais sentido agora.
Se terminarmos de escrever o código do Simulador, devemos implantá-lo na rede de produção para testá-lo - seja na rede principal ou na rede de teste. Vou implementá-lo na rede principal imediatamente.
Usando o Foundry, você pode implementar esse contrato inteligente com muita facilidade:
forge create --rpc-url <rpc-url> --private-key <private-key> src/SimulatorV1.sol:SimulatorV1
Chamar esse comando com seu URL de RPC (usei o Alchemy) e uma chave privada implementaria seu contrato imediatamente após a compilação automática do código do Solidity. Para obter mais informações sobre isso, consulte a seção abaixo:
A saída do comando acima é a seguinte:
Coloquei o sinalizador ( - legacy) ali, porque fiz a implantação na Polygon Mainnet.
O endereço do contrato do SimulatorV1 é: 0x37384C5D679aeCa03D211833711C277Da470C670
Agora que implantamos nosso contrato, vamos tentar chamar a função de simulação usando Javascript. Isso também deve funcionar com outras linguagens, porque agora sua função de simulação está ativa no blockchain, e qualquer biblioteca web3, como web3.js, ethers.js, web3.py, web3.rs, web3.go, deve funcionar da mesma forma.
Usarei o ethers.js para testar minha função de simulação.
No diretório swap-simulator-v1, crie um projeto Node.js:
npm init
npm install --save-dev [email protected] dotenv
Esse projeto deve funcionar com todas as versões do ethers, mas eu me limitei à versão 5.7.2, porque o Flashbots não funciona com versões superiores a essa, e eu quero usar o Flashbots para esse projeto no futuro.
Em seguida, digite o script JS:
const { ethers } = require("ethers");
require("dotenv").config();
const SimulatorV1ABI = require("./out/SimulatorV1.sol/SimulatorV1.json").abi;
// NÃO USE A CHAVE PRIVADA REAL
const provider = new ethers.providers.JsonRpcProvider(process.env.ALCHEMY_URL);
const signer = new ethers.Wallet(process.env.TEST_PRIVATE_KEY, provider);
const SimulatorV1Address = "0x37384C5D679aeCa03D211833711C277Da470C670";
const contract = new ethers.Contract(
SimulatorV1Address,
SimulatorV1ABI,
signer
);
(async () => {
const swapParam1 = {
protocol: 0,
pool: "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174", // random address
tokenIn: "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174",
tokenOut: "0xc2132D05D31c914a87C6611C10748AEb04B58e8F",
fee: 0,
amount: ethers.utils.parseUnits("1", 6),
};
const swapParam2 = {
protocol: 1,
pool: "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174", // random address
tokenIn: "0xc2132D05D31c914a87C6611C10748AEb04B58e8F",
tokenOut: "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174",
fee: 500,
amount: 0, // no need
};
const swapParam3 = {
protocol: 2,
pool: "0x445FE580eF8d70FF569aB36e80c647af338db351", // real Curve.fi pool address
tokenIn: "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174",
tokenOut: "0xc2132D05D31c914a87C6611C10748AEb04B58e8F",
fee: 0,
amount: 0, // no need
};
const swapParams = [swapParam1, swapParam2, swapParam3];
const amountOut = await contract.callStatic.simulateSwapIn(swapParams);
console.log(amountOut.toString());
})();
Execute-o como está e ele deverá funcionar na rede principal, pois está implantado lá.
Estou tentando simular caminhos de troca usando 1 USDC:
(UniswapV2) USDC → USDT
(UniswapV3) USDT → USDC
(Curve Finance) USDC → USDT
O resultado final será: 996819 (= 0,996819 USDT).
Um caminho bastante inútil para simular, mas que demonstra bem o propósito.
Conclusão
Esta postagem acabou ficando muito longa. Portanto, para aqueles que gostam de mergulhar no código imediatamente, podem consultar meu repositório do Github em:
Além disso, para as pessoas que estão apenas começando a jornada de criação de bots MEV, vocês não estão sozinhos. Eu comecei há algumas semanas, com um pouco de conhecimento/experiência em negociação CeFi, e sinto que conversar com as pessoas é a maneira mais segura de resolver muitos problemas aqui. Também sofro com a falta de conteúdo nesse domínio, mas isso é bastante compreensível.
Portanto, siga-me no Twitter, pois lá poderemos conversar sobre tópicos relacionados com mais profundidade! Vejo você na próxima postagem :)
Artigo escrito por Solid Quant. A versão original pode ser encontrada aqui. Traduzido e adaptado por Dimitris Calixto.
Top comments (0)