Pressione ESC para fechar

Arbitragem Flash Loan no DEX: Passo a Passo e Código

Diferenças de preço para o mesmo par nos pools da Uniswap v3 e Sushiswap acontecem toda hora. Geralmente, isso rola em momentos de alta volatilidade, quando uma ordem grande joga o preço para baixo em um pool e o outro ainda não teve tempo de acompanhar. O Flash Loan (empréstimo instantâneo) permite capturar essa diferença de preço sem precisar colocar um tostão do seu próprio capital na jogada.

Um Flash Loan nada mais é do que uma transação atômica na EVM: você pega $1.000.000 emprestado em um protocolo de lending (Aave, Balancer, Uniswap), compra o ativo mais barato na DEX A, vende mais caro na DEX B, devolve o valor principal do empréstimo acrescido da taxa e embolsa o lucro líquido. Se qualquer etapa dessa cadeia de chamadas falhar ou se o saldo final for menor que o valor a ser devolvido, a EVM dá um rollback (revert) em toda a transação. O seu único prejuízo é o gás gasto.

1. Mecânica de mercado e os riscos ocultos da arbitragem “sem risco”

O termo “sem risco” se refere exclusivamente à ausência de risco direcional de mercado e ao risco de perder o seu depósito principal. Na prática, porém, o dev/trader esbarra em outros gargalos críticos.

Atomic Transaction
 

Principais pegadinhas

  • Slippage (Deslizamento de preço): Quanto maior o volume do seu Flash Loan, mais a sua própria ordem empurra o preço dentro do pool (price impact). Se o tamanho do empréstimo passar do ponto ideal, o spread é totalmente corroído pelo slippage.
  • MEV e Front-running: Suas transações na mempool pública são prato cheio para os bots de busca (searchers). Eles conseguem atravessar a sua operação inserindo um swap próprio com uma taxa de gás maior (Priority Fee).
  • Custo de Gás (Gas Overhead): Chamar um smart contract complexo com múltiplos swaps externos consome brincando entre 250.000 e 450.000 gas units. Na Ethereum L1, com o gás rodando a 30-50 Gwei, o custo de execução engole micro-spreads num piscar de olhos.

2. Unit Economics e a matemática para calcular o tamanho do empréstimo

Para mandar bem na arbitragem, você precisa calcular com precisão milimétrica o volume de empréstimo x que maximiza o seu lucro líquido.

Em pools de Produto Constante (x · y = k, como na Uniswap v2), o preço depende das reservas Rx e Ry. Ao realizar um swap de valor Δx, o comprador recebe:

Δy = (Ry · Δx · (1 - γ)) / (Rx + Δx · (1 - γ))

onde γ é a taxa do pool (por exemplo, 0.003 para 0.3%).

Modelo financeiro de lucro

Profit(x) = SwapOutDEX2(SwapOutDEX1(x)) - x - FlashLoanFee(x) - GasCost

Taxas de empréstimo instantâneo nos principais provedores:

  • Balancer v2: 0%
  • Uniswap v3 (Flash Swaps): varia conforme a tier do pool (0.05%, 0.3%, 1.0%)
  • Aave v3: 0.05%

3. Comparativo de provedores de liquidez

ProvedorTaxa de EmpréstimoCusto de GásCondições Especiais
Balancer v20.00%BaixoExige ponto de entrada único pelo Vault, perfeito para começar
Aave v30.05%MédioLiquidez pesada, suporte a dezenas de redes (Polygon, Arbitrum, Optimism)
Uniswap v30.05% – 0.30%AltoPermite rodar Flash Swaps direto do pool, sem precisar recorrer a protocolos de lending externos

4. Passo a passo do algoritmo de execução de arbitragem

  1. Avaliação do spread: Monitorar a diferença de preço entre a DEX A (preço baixo) e a DEX B (preço alto) via conexão WebSocket com o nó ou gRPC streams.
  2. Cálculo do lucro: Encontrar o valor ideal de x considerando as taxas dos pools, a porcentagem da Balancer/Aave e o Base Fee + Priority Fee do momento.
  3. Disparo da transação: O contrato solicita o Flash Loan ao Vault da Balancer.
  4. Recebimento dos fundos: O Vault envia os tokens solicitados para o contrato de arbitragem e chama a função de callback receiveFlashLoan.
  5. Swap 1: O contrato envia o token recebido para a DEX A (Uniswap v3) e recebe o ativo alvo em troca.
  6. Swap 2: O contrato envia o ativo alvo para a DEX B (Sushiswap/Uniswap v2), trocando-o de volta pelo token inicial.
  7. Devolução do empréstimo: O contrato aprova (approve) ou transfere diretamente o valor principal do empréstimo para o Vault da Balancer.
  8. Check de lucratividade: O contrato valida se o saldo final é maior que o saldo inicial. Se não for, lança um revert.

5. Smart Contract pronto em Solidity (OpenZeppelin + Balancer v2)

Este contrato foi implementado para redes EVM. Ele roda usando a Balancer v2 como provedora de Flash Loan com taxa zero.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
interface IBalancerVault {
    function flashLoan(
        address recipient,
        IERC20[] memory tokens,
        uint256[] memory amounts,
        bytes memory userData
    ) external;
}
interface ISwapRouterV3 {
    struct ExactInputSingleParams {
        address tokenIn;
        address tokenOut;
        uint24 fee;
        address recipient;
        uint256 deadline;
        uint256 amountIn;
        uint256 amountOutMinimum;
        uint160 sqrtPriceLimitX96;
    }
    function exactInputSingle(
        ExactInputSingleParams calldata params
    ) external returns (uint256 amountOut);
}
interface IUniswapV2Router {
    function swapExactTokensForTokens(
        uint256 amountIn,
        uint256 amountOutMin,
        address[] calldata path,
        address to,
        uint256 deadline
    ) external returns (uint256[] memory amounts);
}
contract FlashArbEngine {
    using SafeERC20 for IERC20;
    address private immutable owner;
    IBalancerVault private immutable balancerVault;
    ISwapRouterV3 private immutable uniswapV3Router;
    IUniswapV2Router private immutable sushiswapRouter;
    bool private arbitrageActive;
    error NotOwner();
    error ZeroAddress();
    error IdenticalTokens();
    error InvalidAmount();
    error ArbitrageActive();
    error InvalidCaller();
    error InvalidArrayLength();
    error InsufficientProfit();
    struct ArbParams {
        address tokenA;
        address tokenB;
        uint24 poolFeeV3;
        uint256 loanAmount;
        uint256 minAmountOutV3; // Calculado off-chain para proteger o 1º swap
        uint256 minProfit;
    }
    modifier onlyOwner() {
        if (msg.sender != owner) revert NotOwner();
        _;
    }
    constructor(
        address _balancerVault,
        address _uniswapV3Router,
        address _sushiswapRouter
    ) {
        if (_balancerVault == address(0) || _uniswapV3Router == address(0) || _sushiswapRouter == address(0)) {
            revert ZeroAddress();
        }
        owner = msg.sender;
        balancerVault = IBalancerVault(_balancerVault);
        uniswapV3Router = ISwapRouterV3(_uniswapV3Router);
        sushiswapRouter = IUniswapV2Router(_sushiswapRouter);
    }
    function executeArbitrage(ArbParams calldata params) external onlyOwner {
        if (arbitrageActive) revert ArbitrageActive();
        if (params.tokenA == address(0) || params.tokenB == address(0)) revert ZeroAddress();
        if (params.tokenA == params.tokenB) revert IdenticalTokens();
        if (params.loanAmount == 0 || params.minProfit == 0 || params.minAmountOutV3 == 0) revert InvalidAmount();
        arbitrageActive = true;
        IERC20[] memory tokens = new IERC20[](1);
        tokens[0] = IERC20(params.tokenA);
        uint256[] memory amounts = new uint256[](1);
        amounts[0] = params.loanAmount;
        balancerVault.flashLoan(
            address(this),
            tokens,
            amounts,
            abi.encode(params)
        );
        arbitrageActive = false;
    }
    function receiveFlashLoan(
        IERC20[] memory tokens,
        uint256[] memory amounts,
        uint256[] memory feeAmounts,
        bytes memory userData
    ) external {
        if (msg.sender != address(balancerVault)) revert InvalidCaller();
        if (!arbitrageActive) revert ArbitrageActive();
        // Validação da estrutura dos arrays retornados pela Balancer
        if (tokens.length != 1 || amounts.length != 1 || feeAmounts.length != 1) {
            revert InvalidArrayLength();
        }
        ArbParams memory params = abi.decode(userData, (ArbParams));
        uint256 loanAmount = amounts[0];
        uint256 repayAmount = loanAmount + feeAmounts[0];
        // Grava o saldo inicial ANTES de executar os swaps (inclui o empréstimo recebido + saldo residual antigo)
        uint256 balanceBefore = IERC20(params.tokenA).balanceOf(address(this));
        // 1. Swap TokenA -> TokenB na Uniswap v3 (proteção dinâmica contra slippage)
        IERC20(params.tokenA).forceApprove(address(uniswapV3Router), loanAmount);
        ISwapRouterV3.ExactInputSingleParams memory swapParams =
            ISwapRouterV3.ExactInputSingleParams({
                tokenIn: params.tokenA,
                tokenOut: params.tokenB,
                fee: params.poolFeeV3,
                recipient: address(this),
                deadline: block.timestamp,
                amountIn: loanAmount,
                amountOutMinimum: params.minAmountOutV3,
                sqrtPriceLimitX96: 0
            });
        uint256 tokenBBalanceBefore = IERC20(params.tokenB).balanceOf(address(this));
        uniswapV3Router.exactInputSingle(swapParams);
        uint256 tokenBReceived = IERC20(params.tokenB).balanceOf(address(this)) - tokenBBalanceBefore;
        // 2. Swap TokenB -> TokenA na SushiSwap (define o mínimo de saída = repayAmount + minProfit)
        IERC20(params.tokenB).forceApprove(address(sushiswapRouter), tokenBReceived);
        address[] memory path = new address[](2);
        path[0] = params.tokenB;
        path[1] = params.tokenA;
        sushiswapRouter.swapExactTokensForTokens(
            tokenBReceived,
            repayAmount + params.minProfit,
            path,
            address(this),
            block.timestamp
        );
        // 3. Checa se há saldo suficiente ANTES de devolver o empréstimo
        uint256 balanceBeforeRepayment = IERC20(params.tokenA).balanceOf(address(this));
        if (balanceBeforeRepayment < repayAmount + params.minProfit) {
            revert InsufficientProfit();
        }
        // 4. Devolve o empréstimo para o Vault da Balancer
        IERC20(params.tokenA).safeTransfer(
            address(balancerVault),
            repayAmount
        );
        // 5. Garante que o lucro líquido da operação foi mantido, considerando o saldo antigo
        uint256 balanceAfter = IERC20(params.tokenA).balanceOf(address(this));
        if (balanceAfter < balanceBefore - loanAmount + params.minProfit) {
            revert InsufficientProfit();
        }
    }
    function withdrawToken(address token) external onlyOwner {
        if (arbitrageActive) revert ArbitrageActive();
        uint256 balance = IERC20(token).balanceOf(address(this));
        if (balance == 0) revert InvalidAmount();
        
        IERC20(token).safeTransfer(owner, balance);
    }
}

Para tokens ERC-20 padrão, a ordem dos swaps e da devolução do empréstimo neste código está 100% alinhada com a lógica de execução. Antes de subir em produção na rede principal, não se esqueça de validar os endereços dos roteadores, a taxa real da Balancer e os valores de minAmountOutV3 para cada transação específica.

6. Proteção contra atravessadores (Proteção MEV)

Mandar uma transação de arbitragem através de RPCs públicos padrão (Infura, Alchemy) para a mempool pública é pedir para entregar seu lucro de bandeja para os bots de sandwich. Os searchers vivem escaneando operações de entrada e usam e abusam de Front-running e Back-running.

Solução prática: RPC Privado (Flashbots Builder)

  1. Envie suas transações direto para os builders de blocos (Flashbots, Beaverbuild, Titan) usando endpoints privados (https://rpc.flashbots.net).
  2. Para devs utilizando Node.js / Python, faça o envio por meio de bundles (eth_sendBundle), definindo a gorjeta do validador (Priority Fee) para ser paga somente em caso de execução bem-sucedida.

7. Interagindo com a Infraestrutura MEV via Flashbots (Node.js / Viem)

Enviar transações de arbitragem publicamente usando o eth_sendTransaction padrão em 2026 é receita certa para tomar prejuízo por causa dos bots de MEV. A única forma de blindar sua operação é enviando um bundle atômico direto para os block builders (Flashbots, Titan, Beaverbuild, BuilderNet).

Se as condições na DEX mudarem e o spread fechar antes da transação entrar no bloco, o script de simulação vai descartar o bundle e o builder simplesmente não vai incluir sua transação. Você não queima nem um único Wei de gas.

import {
    FlashbotsBundleProvider,
    FlashbotsBundleResolution
} from '@flashbots/ethers-provider-bundle';
import { providers, Wallet, utils } from 'ethers';
const CHAIN_ID = 1;
const RELAY_URL = 'https://relay.flashbots.net';
const INITIAL_GAS_LIMIT = 400000;
const GAS_BUFFER_PERCENT = 120;
const PRIORITY_FEE_GWEI = '3';
if (
    !process.env.ETH_RPC_URL ||
    !process.env.FLASHBOTS_AUTH_KEY ||
    !process.env.EXECUTOR_PRIVATE_KEY
) {
    throw new Error('[Flashbots] Variáveis de ambiente obrigatórias não foram definidas');
}
const provider = new providers.JsonRpcProvider(process.env.ETH_RPC_URL);
const authSigner = new Wallet(process.env.FLASHBOTS_AUTH_KEY);
const executorWallet = new Wallet(
    process.env.EXECUTOR_PRIVATE_KEY,
    provider
);
// Processamento sequencial de bundles dentro da mesma instância do Node.js.
let executionQueue = Promise.resolve();
export function queueArbitrageBundle(
    arbContractAddress,
    calldata,
    targetBlockNumber
) {
    const task = executionQueue.then(() =>
        processBundle(arbContractAddress, calldata, targetBlockNumber)
    );
    executionQueue = task.catch((error) => {
        console.error('[Bundle Queue Error]', error);
    });
    return task;
}
async function processBundle(
    arbContractAddress,
    calldata,
    targetBlockNumber
) {
    if (!utils.isAddress(arbContractAddress)) {
        throw new Error('[Flashbots] Endereço do contrato de arbitragem inválido');
    }
    if (
        typeof calldata !== 'string' ||
        !utils.isHexString(calldata)
    ) {
        throw new Error('[Flashbots] Calldata inválido');
    }
    if (
        !Number.isSafeInteger(targetBlockNumber) ||
        targetBlockNumber < 1
    ) {
        throw new Error('[Flashbots] Número do bloco alvo inválido');
    }
    const network = await provider.getNetwork();
    if (network.chainId !== CHAIN_ID) {
        throw new Error(
            `[Flashbots] Rede incorreta: ${network.chainId}; esperada Mainnet (${CHAIN_ID})`
        );
    }
    const flashbotsProvider = await FlashbotsBundleProvider.create(
        provider,
        authSigner,
        RELAY_URL,
        CHAIN_ID
    );
    const latestBlock = await provider.getBlockNumber();
    if (targetBlockNumber <= latestBlock) {
        console.warn(
            `[Flashbots] Bloco alvo ${targetBlockNumber} já passou`
        );
        return;
    }
    // Sincronização do nonce com o RPC público.
    // A fila protege apenas esta instância do Node.js.
    const nonce = await provider.getTransactionCount(
        executorWallet.address,
        'pending'
    );
    const feeData = await provider.getFeeData();
    const maxPriorityFeePerGas = utils.parseUnits(
        PRIORITY_FEE_GWEI,
        'gwei'
    );
    const maxFeePerGas = feeData.lastBaseFeePerGas
        ? feeData.lastBaseFeePerGas.mul(2).add(maxPriorityFeePerGas)
        : utils.parseUnits('60', 'gwei');
    const transaction = {
        chainId: CHAIN_ID,
        type: 2,
        to: arbContractAddress,
        data: calldata,
        value: 0,
        nonce,
        gasLimit: INITIAL_GAS_LIMIT,
        maxFeePerGas,
        maxPriorityFeePerGas
    };
    // Primeira simulação com o limite inicial de gas.
    let signedBundle = await flashbotsProvider.signBundle([
        {
            signer: executorWallet,
            transaction
        }
    ]);
    let simulation = await flashbotsProvider.simulate(
        signedBundle,
        targetBlockNumber
    );
    if ('error' in simulation) {
        console.error(
            `[Simulation Error] ${simulation.error.message}`
        );
        return;
    }
    if (simulation.firstRevert) {
        console.warn(
            '[Simulation Revert]',
            simulation.firstRevert
        );
        return;
    }
    const firstResult = simulation.results?.[0];
    if (!firstResult || firstResult.error || firstResult.gasUsed == null) {
        console.error(
            '[Simulation Error] Resultado da transação com sucesso ausente'
        );
        return;
    }
    const txGasUsed = utils.BigNumber.from(firstResult.gasUsed);
    if (txGasUsed.lte(0)) {
        console.error('[Simulation Error] Consumo de gas inválido');
        return;
    }
    // Aumento do limite de gas com margem de 20%.
    const adjustedGasLimit = txGasUsed
        .mul(GAS_BUFFER_PERCENT)
        .div(100);
    if (adjustedGasLimit.gt(INITIAL_GAS_LIMIT)) {
        console.error(
            `[Gas Limit] Limite calculado ${adjustedGasLimit.toString()} excede o limite inicial de ${INITIAL_GAS_LIMIT}`
        );
        return;
    }
    transaction.gasLimit = adjustedGasLimit;
    // Re-assinatura após alterar o gasLimit.
    signedBundle = await flashbotsProvider.signBundle([
        {
            signer: executorWallet,
            transaction
        }
    ]);
    // Re-simulação da transação assinada.
    simulation = await flashbotsProvider.simulate(
        signedBundle,
        targetBlockNumber
    );
    if ('error' in simulation) {
        console.error(
            `[Second Simulation Error] ${simulation.error.message}`
        );
        return;
    }
    if (simulation.firstRevert) {
        console.warn(
            '[Second Simulation Revert]',
            simulation.firstRevert
        );
        return;
    }
    const secondResult = simulation.results?.[0];
    if (
        !secondResult ||
        secondResult.error ||
        secondResult.gasUsed == null
    ) {
        console.error(
            '[Second Simulation Error] A transação não passou na validação'
        );
        return;
    }
    // Garante que o bloco ainda não foi minerado.
    const blockBeforeSubmission = await provider.getBlockNumber();
    if (targetBlockNumber <= blockBeforeSubmission) {
        console.warn(
            `[Flashbots] Bloco ${targetBlockNumber} já passou após a simulação`
        );
        return;
    }
    // Envia o bundle visando estritamente o bloco especificado.
    const submission = await flashbotsProvider.sendRawBundle(
        signedBundle,
        targetBlockNumber
    );
    if ('error' in submission) {
        console.error(
            `[Submission Error] ${submission.error.message}`
        );
        return;
    }
    console.log(
        `[Flashbots] Bundle enviado para o bloco ${targetBlockNumber}; nonce=${nonce}; gasLimit=${adjustedGasLimit.toString()}`
    );
    const resolution = await submission.wait();
    switch (resolution) {
        case FlashbotsBundleResolution.BundleIncluded:
            console.log(
                `[Success] Bundle incluído no bloco ${targetBlockNumber}`
            );
            break;
        case FlashbotsBundleResolution.BlockPassedWithoutInclusion:
            console.log(
                `[Missed] Bloco ${targetBlockNumber} minerado sem o bundle`
            );
            break;
        case FlashbotsBundleResolution.AccountNonceTooHigh:
            console.error(
                '[Nonce Error] Nonce do executor está à frente do esperado'
            );
            break;
        default:
            console.warn(
                `[Flashbots] Resultado da execução: ${resolution}`
            );
    }
}

8. Arquitetura de Monitoramento Off-Chain e Busca de Spreads

Ficar esperando atualização via polling (setInterval ou REST API) introduz um delay monstro de 500–1500 ms, o que mata qualquer chance de fechar um arbitragem com sucesso. Em sistemas em produção, o padrão é usar uma arquitetura reativa rodando em Node.js, Go ou Rust.

Bot Architectural Pipeline

Principais componentes do bot

  • 1. In-Memory State unificado: Armazenamento de reservas atuais (para o Uniswap v2) e ticks vetoriais (para o Uniswap v3) diretamente na memória do processo Node.js/Rust.
  • 2. Inscrição em logs de pools: Conexão via WebSocket aos eventos Sync (v2) e Swap (v3).
  • 3. Recálculo instantâneo: Ao receber um novo log, apenas o par afetado pelas mudanças é recalculado, eliminando a necessidade de fazer polling em todos os contratos.

// arbitrage-monitor.mjs
// Reactive off-chain Uniswap V2/V3 pool monitor.
// WebSocket events, unified in-memory state, pair-local recalculation.
// Spread detection only; no transaction execution.
import {
    createPublicClient,
    webSocket,
    parseAbi,
    getAddress,
} from 'viem';
import { mainnet } from 'viem/chains';
// -----------------------------------------------------------------------------
// Configuration
// -----------------------------------------------------------------------------
const RPC_URL = process.env.ETH_WS_URL;
if (!RPC_URL) {
    throw new Error('ETH_WS_URL is required');
}
const client = createPublicClient({
    chain: mainnet,
    transport: webSocket(RPC_URL, {
        reconnect: true,
        retryCount: 10,
        retryDelay: 1000,
        keepAlive: {
            interval: 15_000,
        },
    }),
});
const V2_FACTORY = getAddress(
    '0x5C69bEe701ef814a2B6a3EDD4B1652CB9cc5aA6f',
);
const V3_FACTORY = getAddress(
    '0x1F98431c8aD98523631AE4a59f267346ea31F984',
);
const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000';
// Set POOLS_JSON to a JSON array of known pools.
// Example:
// [{"version":2,"address":"0x..."},
//  {"version":3,"address":"0x...","fee":3000}]
const INITIAL_POOLS = JSON.parse(process.env.POOLS_JSON || '[]');
const V2_FEE_PPM = 3000;
const FEE_DENOMINATOR = 1_000_000;
// -----------------------------------------------------------------------------
// ABIs
// -----------------------------------------------------------------------------
const ERC20_ABI = parseAbi([
    'function decimals() view returns (uint8)',
    'function symbol() view returns (string)',
]);
const V2_FACTORY_ABI = parseAbi([
    'event PairCreated(address indexed token0, address indexed token1, address pair, uint256)',
]);
const V2_PAIR_ABI = parseAbi([
    'event Sync(uint112 reserve0, uint112 reserve1)',
    'function token0() view returns (address)',
    'function token1() view returns (address)',
    'function getReserves() view returns (uint112 reserve0, uint112 reserve1, uint32 blockTimestampLast)',
]);
const V3_FACTORY_ABI = parseAbi([
    'event PoolCreated(address indexed token0, address indexed token1, uint24 indexed fee, int24 tickSpacing, address pool)',
]);
const V3_POOL_ABI = parseAbi([
    'event Swap(address indexed sender, address indexed recipient, int256 amount0, int256 amount1, uint160 sqrtPriceX96, uint128 liquidity, int24 tick)',
    'function token0() view returns (address)',
    'function token1() view returns (address)',
    'function slot0() view returns (uint160 sqrtPriceX96, int24 tick, uint16 observationIndex, uint16 observationCardinality, uint16 observationCardinalityNext, uint8 feeProtocol, bool unlocked)',
    'function liquidity() view returns (uint128)',
]);
// -----------------------------------------------------------------------------
// Unified in-memory state
// -----------------------------------------------------------------------------
// pools: checksummed pool address -> pool state
// pairs: normalized token pair -> Set of pool addresses
// unwatchPool: pool address -> unsubscribe function
// initializing: prevents duplicate concurrent initialization
const pools = new Map();
const pairs = new Map();
const unwatchPool = new Map();
const initializing = new Set();
function normalizeAddress(address) {
    return getAddress(address);
}
function pairKey(token0, token1) {
    const a = normalizeAddress(token0).toLowerCase();
    const b = normalizeAddress(token1).toLowerCase();
    return a < b ? `${a}:${b}` : `${b}:${a}`;
}
function registerPool(pool) {
    pools.set(pool.address, pool);
    const key = pairKey(pool.token0, pool.token1);
    if (!pairs.has(key)) {
        pairs.set(key, new Set());
    }
    pairs.get(key).add(pool.address);
}
function registerMetadata(pool, token0Metadata, token1Metadata) {
    pool.token0Decimals = token0Metadata.decimals;
    pool.token1Decimals = token1Metadata.decimals;
    pool.token0Symbol = token0Metadata.symbol;
    pool.token1Symbol = token1Metadata.symbol;
}
async function readTokenMetadata(address) {
    const [decimals, symbol] = await Promise.all([
        client.readContract({
            address,
            abi: ERC20_ABI,
            functionName: 'decimals',
        }),
        client.readContract({
            address,
            abi: ERC20_ABI,
            functionName: 'symbol',
        }).catch(() => address.slice(0, 10)),
    ]);
    if (decimals > 36) {
        throw new Error(`Unsupported token decimals: ${address}`);
    }
    return { decimals, symbol };
}
// -----------------------------------------------------------------------------
// Price calculation
// -----------------------------------------------------------------------------
// Prices are token1 per token0, expressed in human-readable token units.
// Number is used only for preliminary ranking, not transaction amounts.
function getV2Price(pool) {
    if (pool.reserve0 === 0n || pool.reserve1 === 0n) {
        return null;
    }
    const rawRatio =
        Number(pool.reserve1) / Number(pool.reserve0);
    return rawRatio *
        10 ** (pool.token0Decimals - pool.token1Decimals);
}
function getV3Price(pool) {
    if (pool.sqrtPriceX96 === 0n) {
        return null;
    }
    const sqrtRatio =
        Number(pool.sqrtPriceX96) / 2 ** 96;
    const rawRatio = sqrtRatio * sqrtRatio;
    return rawRatio *
        10 ** (pool.token0Decimals - pool.token1Decimals);
}
function getPoolPrice(pool) {
    if (pool.version === 2) return getV2Price(pool);
    if (pool.version === 3) return getV3Price(pool);
    return null;
}
function getFeeRate(pool) {
    // Uniswap V2: 0.30% swap fee.
    // Uniswap V3 fee values are denominated in millionths.
    const fee = pool.version === 2 ? V2_FEE_PPM : pool.fee;
    return fee / FEE_DENOMINATOR;
}
// -----------------------------------------------------------------------------
// Candidate detection: recalculate only the affected token pair
// -----------------------------------------------------------------------------
function findSpreadCandidates(poolStates) {
    const candidates = [];
    for (let i = 0; i < poolStates.length; i++) {
        for (let j = i + 1; j < poolStates.length; j++) {
            const a = poolStates[i];
            const b = poolStates[j];
            const priceA = getPoolPrice(a);
            const priceB = getPoolPrice(b);
            if (
                priceA === null ||
                priceB === null ||
                !Number.isFinite(priceA) ||
                !Number.isFinite(priceB) ||
                priceA <= 0 ||
                priceB <= 0
            ) {
                continue;
            }
            const [buyPool, sellPool, buyPrice, sellPrice] =
                priceA <= priceB
                    ? [a, b, priceA, priceB]
                    : [b, a, priceB, priceA];
            const feeBuy = getFeeRate(buyPool);
            const feeSell = getFeeRate(sellPool);
            if (
                feeBuy < 0 || feeBuy >= 1 ||
                feeSell < 0 || feeSell >= 1
            ) {
                continue;
            }
            // Approximate fee-adjusted spread.
            // Does not account for price impact or trade size.
            const effectiveBuyPrice = buyPrice / (1 - feeBuy);
            const effectiveSellPrice = sellPrice * (1 - feeSell);
            const grossSpreadPercent =
                ((sellPrice - buyPrice) / buyPrice) * 100;
            const estimatedSpreadPercent =
                ((effectiveSellPrice - effectiveBuyPrice) /
                    effectiveBuyPrice) * 100;
            if (grossSpreadPercent <= 0) continue;
            candidates.push({
                token0: a.token0,
                token1: a.token1,
                token0Symbol: a.token0Symbol,
                token1Symbol: a.token1Symbol,
                buyPool: buyPool.address,
                sellPool: sellPool.address,
                buyVersion: buyPool.version,
                sellVersion: sellPool.version,
                buyPrice,
                sellPrice,
                grossSpreadPercent,
                estimatedSpreadPercent,
            });
        }
    }
    return candidates;
}
function onPoolUpdated(pool) {
    const addresses = pairs.get(
        pairKey(pool.token0, pool.token1),
    );
    if (!addresses) return;
    const states = [];
    for (const address of addresses) {
        const state = pools.get(address);
        if (state) states.push(state);
    }
    for (const candidate of findSpreadCandidates(states)) {
        console.log('[Spread candidate]', {
            pair: `${candidate.token0Symbol}/${candidate.token1Symbol}`,
            buyPool: candidate.buyPool,
            sellPool: candidate.sellPool,
            buyVersion: candidate.buyVersion,
            sellVersion: candidate.sellVersion,
            buyPrice: candidate.buyPrice,
            sellPrice: candidate.sellPrice,
            grossSpreadPercent: candidate.grossSpreadPercent,
            estimatedSpreadPercent: candidate.estimatedSpreadPercent,
        });
        // Next stage:
        // 1. Calculate optimal trade size x*.
        // 2. Simulate both swaps against current pool state.
        // 3. Deduct gas, flash-loan costs and execution costs.
        // 4. Simulate the complete transaction.
        // 5. Submit only if expected net profit exceeds the configured threshold.
    }
}
// -----------------------------------------------------------------------------
// V2 pool subscription
// -----------------------------------------------------------------------------
async function subscribeV2Pool(rawAddress) {
    const address = normalizeAddress(rawAddress);
    if (
        pools.has(address) ||
        initializing.has(address) ||
        unwatchPool.has(address)
    ) {
        return;
    }
    initializing.add(address);
    let queuedLogs = [];
    let ready = false;
    let unwatch;
    try {
        // Subscribe before reading the snapshot to reduce the initialization gap.
        unwatch = client.watchContractEvent({
            address,
            abi: V2_PAIR_ABI,
            eventName: 'Sync',
            strict: true,
            onLogs(logs) {
                if (!ready) {
                    queuedLogs.push(...logs);
                    return;
                }
                processV2Logs(address, logs);
            },
            onError(error) {
                console.error(`[V2 ${address}]`, error.message);
            },
        });
        unwatchPool.set(address, unwatch);
        const [token0, token1] = await Promise.all([
            client.readContract({
                address,
                abi: V2_PAIR_ABI,
                functionName: 'token0',
            }),
            client.readContract({
                address,
                abi: V2_PAIR_ABI,
                functionName: 'token1',
            }),
        ]);
        const normalizedToken0 = normalizeAddress(token0);
        const normalizedToken1 = normalizeAddress(token1);
        const [metadata0, metadata1, reserves, blockNumber] =
            await Promise.all([
                readTokenMetadata(normalizedToken0),
                readTokenMetadata(normalizedToken1),
                client.readContract({
                    address,
                    abi: V2_PAIR_ABI,
                    functionName: 'getReserves',
                }),
                client.getBlockNumber(),
            ]);
        const pool = {
            address,
            version: 2,
            token0: normalizedToken0,
            token1: normalizedToken1,
            reserve0: reserves[0],
            reserve1: reserves[1],
            fee: V2_FEE_PPM,
            lastBlock: blockNumber,
            lastLogIndex: -1,
        };
        registerMetadata(pool, metadata0, metadata1);
        registerPool(pool);
        ready = true;
        // Logs already reflected in the snapshot are discarded.
        // Later logs are applied in block/log order.
        queuedLogs.sort(compareLogs);
        for (const log of queuedLogs) {
            if (log.blockNumber > blockNumber) {
                processV2Logs(address, [log]);
            }
        }
        queuedLogs = [];
        onPoolUpdated(pool);
        console.log('[V2 subscribed]', address);
    } catch (error) {
        unwatch?.();
        unwatchPool.delete(address);
        console.error(`[V2 init ${address}]`, error.message);
        throw error;
    } finally {
        initializing.delete(address);
    }
}
function compareLogs(a, b) {
    if (a.blockNumber < b.blockNumber) return -1;
    if (a.blockNumber > b.blockNumber) return 1;
    return (a.logIndex ?? 0) - (b.logIndex ?? 0);
}
function processV2Logs(address, logs) {
    const pool = pools.get(address);
    if (!pool) return;
    for (const log of [...logs].sort(compareLogs)) {
        if (log.removed) {
            console.error('[V2 reorg detected]', address);
            continue;
        }
        const block = log.blockNumber ?? 0n;
        const index = log.logIndex ?? 0;
        if (
            block < pool.lastBlock ||
            (block === pool.lastBlock && index <= pool.lastLogIndex)
        ) {
            continue;
        }
        const { reserve0, reserve1 } = log.args;
        if (reserve0 === undefined || reserve1 === undefined) {
            continue;
        }
        pool.reserve0 = reserve0;
        pool.reserve1 = reserve1;
        pool.lastBlock = block;
        pool.lastLogIndex = index;
        onPoolUpdated(pool);
    }
}
// -----------------------------------------------------------------------------
// V3 pool subscription
// -----------------------------------------------------------------------------
async function subscribeV3Pool(rawAddress, fee) {
    const address = normalizeAddress(rawAddress);
    if (
        pools.has(address) ||
        initializing.has(address) ||
        unwatchPool.has(address)
    ) {
        return;
    }
    initializing.add(address);
    let queuedLogs = [];
    let ready = false;
    let unwatch;
    try {
        unwatch = client.watchContractEvent({
            address,
            abi: V3_POOL_ABI,
            eventName: 'Swap',
            strict: true,
            onLogs(logs) {
                if (!ready) {
                    queuedLogs.push(...logs);
                    return;
                }
                processV3Logs(address, logs);
            },
            onError(error) {
                console.error(`[V3 ${address}]`, error.message);
            },
        });
        unwatchPool.set(address, unwatch);
        const [token0, token1] = await Promise.all([
            client.readContract({
                address,
                abi: V3_POOL_ABI,
                functionName: 'token0',
            }),
            client.readContract({
                address,
                abi: V3_POOL_ABI,
                functionName: 'token1',
            }),
        ]);
        const normalizedToken0 = normalizeAddress(token0);
        const normalizedToken1 = normalizeAddress(token1);
        const [metadata0, metadata1, slot0, liquidity, blockNumber] =
            await Promise.all([
                readTokenMetadata(normalizedToken0),
                readTokenMetadata(normalizedToken1),
                client.readContract({
                    address,
                    abi: V3_POOL_ABI,
                    functionName: 'slot0',
                }),
                client.readContract({
                    address,
                    abi: V3_POOL_ABI,
                    functionName: 'liquidity',
                }),
                client.getBlockNumber(),
            ]);
        const pool = {
            address,
            version: 3,
            token0: normalizedToken0,
            token1: normalizedToken1,
            sqrtPriceX96: slot0[0],
            tick: slot0[1],
            liquidity,
            fee,
            lastBlock: blockNumber,
            lastLogIndex: -1,
        };
        registerMetadata(pool, metadata0, metadata1);
        registerPool(pool);
        ready = true;
        queuedLogs.sort(compareLogs);
        for (const log of queuedLogs) {
            if (log.blockNumber > blockNumber) {
                processV3Logs(address, [log]);
            }
        }
        queuedLogs = [];
        onPoolUpdated(pool);
        console.log('[V3 subscribed]', address, 'fee:', fee);
    } catch (error) {
        unwatch?.();
        unwatchPool.delete(address);
        console.error(`[V3 init ${address}]`, error.message);
        throw error;
    } finally {
        initializing.delete(address);
    }
}
function processV3Logs(address, logs) {
    const pool = pools.get(address);
    if (!pool) return;
    for (const log of [...logs].sort(compareLogs)) {
        if (log.removed) {
            console.error('[V3 reorg detected]', address);
            continue;
        }
        const block = log.blockNumber ?? 0n;
        const index = log.logIndex ?? 0;
        if (
            block < pool.lastBlock ||
            (block === pool.lastBlock && index <= pool.lastLogIndex)
        ) {
            continue;
        }
        const { sqrtPriceX96, tick, liquidity } = log.args;
        if (
            sqrtPriceX96 === undefined ||
            tick === undefined ||
            liquidity === undefined
        ) {
            continue;
        }
        pool.sqrtPriceX96 = sqrtPriceX96;
        pool.tick = tick;
        pool.liquidity = liquidity;
        pool.lastBlock = block;
        pool.lastLogIndex = index;
        onPoolUpdated(pool);
    }
}
// -----------------------------------------------------------------------------
// Factory subscriptions: discover newly created pools
// -----------------------------------------------------------------------------
const unwatchV2Factory = client.watchContractEvent({
    address: V2_FACTORY,
    abi: V2_FACTORY_ABI,
    eventName: 'PairCreated',
    strict: true,
    onLogs(logs) {
        for (const log of logs) {
            const address = log.args.pair;
            if (address && address !== ZERO_ADDRESS) {
                subscribeV2Pool(address).catch(() => {});
            }
        }
    },
    onError(error) {
        console.error('[V2 factory]', error.message);
    },
});
const unwatchV3Factory = client.watchContractEvent({
    address: V3_FACTORY,
    abi: V3_FACTORY_ABI,
    eventName: 'PoolCreated',
    strict: true,
    onLogs(logs) {
        for (const log of logs) {
            const { pool, fee } = log.args;
            if (
                pool &&
                fee !== undefined &&
                pool !== ZERO_ADDRESS
            ) {
                subscribeV3Pool(pool, fee).catch(() => {});
            }
        }
    },
    onError(error) {
        console.error('[V3 factory]', error.message);
    },
});
// -----------------------------------------------------------------------------
// Startup
// -----------------------------------------------------------------------------
async function main() {
    for (const entry of INITIAL_POOLS) {
        if (!entry.address || ![2, 3].includes(entry.version)) {
            throw new Error(
                'Each initial pool needs an address and version 2 or 3',
            );
        }
        if (entry.version === 2) {
            await subscribeV2Pool(entry.address);
        } else {
            if (!Number.isInteger(entry.fee)) {
                throw new Error(
                    `V3 pool ${entry.address} requires its fee tier`,
                );
            }
            await subscribeV3Pool(entry.address, entry.fee);
        }
    }
    console.log('[Monitor running] Ethereum mainnet');
}
function shutdown() {
    console.log('[Monitor stopping]');
    unwatchV2Factory();
    unwatchV3Factory();
    for (const unwatch of unwatchPool.values()) {
        unwatch();
    }
    unwatchPool.clear();
    process.exit(0);
}
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
main().catch((error) => {
    console.error('[Startup failed]', error);
    shutdown();
});

Como rodar: 

npm install viem
export ETH_WS_URL='wss://eth-mainnet.g.alchemy.com/v2/SEU_API_KEY'
export POOLS_JSON='[]'
node arbitrage-monitor.mjs

Definir POOLS_JSON='[]' significa que o monitor começará a assinar os pools criados após a inicialização. Para monitorar pools já existentes, passe os endereços e versões deles através da variável POOLS_JSON.

9. Checklist de otimização de gás (Gas Optimization)

Ao rodar lógica pesada em smart contracts, vence quem consome menos units of gas. Cada bloco de 10.000 gás economizados te dá margem para subir a Priority Fee e vencer a corrida no leilão do mempool (PGA).

  • Use Custom Errors em vez de Require: Estruturas como require(condition, "error string") queimam no mínimo 100 a 200 de gás a mais só para armazenar a string de erro. Trocar para if (!condition) revert InsufficientProfit() sai muito mais barato.
  • Aproveite variáveis immutable e constant: Endereços de routers, da Balancer Vault e de tokens base devem ser declarados com a flag immutable. Isso "chumba" os valores direto no bytecode do contrato, eliminando leituras custosas na storage (o infame SLOAD que custa 2.100 gás).
  • Transferências diretas em vez de transferFrom: Se o seu contrato lidar com tokens intermediários, mande-os direto para o endereço do próximo pool usando transfer, evitando chamadas desnecessárias de approve / transferFrom.
  • Transient Storage (TSTORE / TLOAD da EIP-1153):

    Em redes que suportam a EIP-1153, use armazenamento transitório para as travas de reentrancy guard. Isso despenca o custo de escrita de estado de 20.000 para míseros 100 de gás.

10. Guia passo a passo de deploy e testes

  • Passo 1. Fork da mainnet (Foundry / Hardhat)

    Antes de subir o contrato pra valer na rede principal, é obrigatório rodar uma simulação em um fork local da Mainnet trazendo o estado real e atualizado de todos os pools.

    # Subindo um nó fork local com o Anvil (Foundry)
    anvil --fork-url https://eth-mainnet.g.alchemy.com/v2/YOUR_API_KEY --fork-block-number 19800000
  • Passo 2. Deploy do contrato no fork local

    Faça o deploy do contrato FlashArbEngine apontando para os endereços reais da infraestrutura:

    • Balancer Vault v2: 0xBA12222222228d8Ba445958a75a0704d566BF2C8
    • Uniswap v3 SwapRouter02: 0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45
    • Sushiswap V2 Router: 0xd9e1cE17f2641f24aE83637ab66a2cca9C378804
  • Passo 3. Simulação de spread de preço

    Use um script de simulação para mandar uma venda pesada de WETH no pool da Sushiswap, criando manualmente um desequilíbrio de preço e distorção na liquidez.

  • Passo 4. Chamada de executeArbitrage

    Dispare a função do seu contrato executor passando os parâmetros exatos do empréstimo-relâmpago (flash loan). Confira se a transação passa liso, se o principal do empréstimo é devolvido para a Balancer e se o lucro líquido fica garantido no saldo do FlashArbEngine.

Fazer arbitragem com Flash Loan na vida real exige juntar desenvolvimento low-level em Solidity, manha com os protocolos DeFi e um setup de infra off-chain calibrado para MEV. Um smart contract redondinho aproveitando os empréstimos sem taxa da Balancer v2, unido a relays privados de transações, é a receita ideal para montar um bot de trading 100% automatizado.

Resumir este post do blog com:

FAQ

A arbitragem de flash loan utiliza transações atômicas sem garantia dentro de um único bloco EVM, onde a liquidez é emprestada, negociada em pools DEX com discrepâncias de preço e devolvida com taxa. Se o saldo final não cobrir o empréstimo, a EVM executa um revert automático, fazendo com que o trader perca apenas a taxa de gás.

A proteção contra front running e ataques do tipo sandwich é feita contornando totalmente o mempool público. As transações assinadas são enviadas como pacotes diretamente aos construtores de blocos através de relays RPC privados, como o Flashbots, garantindo execução atômica sem visibilidade pública.

A validação de lucro requer uma verificação de saldo em duas etapas dentro da função callback para garantir que o ganho líquido de tokens exceda o limite mínimo antes de reembolsar o vault. A proteção contra slippage é aplicada repassando parâmetros dinâmicos de saída mínima para as chamadas do roteador AMM.
Martyn Borkowski

I am a crypto trader specializing in digital assets and blockchain markets.

My focus is on identifying opportunities, managing risk, and optimizing strategies to achieve consistent growth in the fast-evolving world of cryptocurrency.

Verification & Professional Profiles: X Profile

...

Deixe seu parecer

O seu endereço de e-mail não será publicado. Campos obrigatórios estão marcados *