Appuyez sur ESC pour fermer

Arbitrage Flash Loan DEX : Guide Pas à Pas & Code

Les écarts de prix pour une même paire entre les pools Uniswap v3 et Sushiswap sont fréquents. Cela arrive généralement lors des pics de volatilité, lorsqu'un gros ordre vient impacter le prix sur un pool alors que le second n'a pas encore réagi. Le Flash Loan (prêt éclair) permet de capturer ce spread sans avoir à immobiliser son propre capital.

Un Flash Loan est une transaction atomique sur l'EVM : vous empruntez $1,000,000 auprès d'un protocole de lending (Aave, Balancer, Uniswap), vous achetez l'actif à bas prix sur le DEX A, vous le revendez plus cher sur le DEX B, vous remboursez le capital emprunté majoré des frais, et vous empochez le profit net. Si la moindre étape échoue dans la chaîne d'appels ou si le solde final reste inférieur au montant à rembourser, l'EVM annule (revert) l'intégralité de la transaction. Votre seule perte se limite aux frais de gas.

1. Mécanique de marché et risques cachés de l'arbitrage « sans risque »

Le terme « sans risque » concerne exclusivement l'absence de risque directionnel sur le marché et l'absence de risque de perte du capital de départ. En pratique, le trader reste confronté à d'autres contraintes majeures.

Atomic Transaction
 

Les principaux pièges

  • Slippage (Glissement de prix) : Plus le volume du Flash Loan est élevé, plus votre propre ordre décale le prix au sein du pool (price impact). Si la taille de l'emprunt dépasse le seuil optimal, le spread est intégralement absorbé par le slippage.
  • MEV et Front-running : Vos transactions envoyées sur le mempool public sont visibles par les bots de recherche (searchers). Ils peuvent intercepter l'opportunité en insérant leur propre swap avec un pourboire de gas plus élevé (Priority Fee).
  • Frais de gas (Gas Overhead) : L'exécution d'un smart contract complexe impliquant plusieurs swaps externes consomme entre 250,000 et 450,000 unités de gas. Sur Ethereum L1, avec un gas oscillant entre 30 et 50 Gwei, les coûts d'exécution effacent facilement les micro-spreads.

2. Économie de la transaction et dimensionnement optimal de l'emprunt

Pour réussir un arbitrage, il est indispensable de calculer précisément le montant d'emprunt x qui maximise le profit net.

Sur les pools Constant Product (x · y = k, comme Uniswap v2), le prix dépend des réserves Rx et Ry. Lors d'un swap d'un montant Δx, l'acheteur reçoit :

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

où γ représente la commission du pool (par exemple 0.003 pour 0.3%).

Modèle financier du profit

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

Grille tarifaire des Flash Loans chez les principaux protocoles :

  • Balancer v2 : 0%
  • Uniswap v3 (Flash Swaps) : varie selon le tier du pool (0.05%, 0.3%, 1.0%)
  • Aave v3 : 0.05%

3. Comparatif des fournisseurs de liquidité

FournisseurFrais d'empruntConsommation de gasParticularités
Balancer v20.00%FaibleNécessite un point d'entrée unique via le Vault, idéal pour débuter
Aave v30.05%MoyenneTrès forte liquidité, disponible sur de nombreux réseaux (Polygon, Arbitrum, Optimism)
Uniswap v30.05% – 0.30%ÉlevéePermet d'exécuter un Flash Swap directement depuis le pool sans passer par un prêteur externe

4. Algorithme d'exécution étape par étape

  1. Analyse du spread : Surveillance continue des écarts de prix entre le DEX A (prix bas) et le DEX B (prix élevé) via un flux WebSocket ou gRPC raccordé à un nœud.
  2. Calcul de rentabilité : Estimation du volume optimal x en intégrant les frais des pools, la commission du prêt et le coût Base Fee + Priority Fee du moment.
  3. Initialisation : Le smart contract déclenche la demande de Flash Loan auprès du Vault Balancer.
  4. Réception des fonds : Le Vault transfère les tokens demandés au contrat d'arbitrage et invoque la fonction de callback receiveFlashLoan.
  5. Swap 1 : Le contrat envoie les tokens empruntés sur le DEX A (Uniswap v3) pour obtenir l'actif ciblé.
  6. Swap 2 : Le contrat réinjecte l'actif ciblé sur le DEX B (Sushiswap/Uniswap v2) pour le reconvertir dans le token d'origine.
  7. Remboursement : Le contrat approuve (approve) ou transfert directement le principal de l'emprunt au Vault Balancer.
  8. Contrôle du profit : Le contrat vérifie que le solde final est supérieur au solde initial. Si ce n'est pas le cas, il déclenche un revert.

5. Smart contract clé en main sur Solidity (OpenZeppelin + Balancer v2)

Ce contrat est conçu pour l'écosystème EVM. Il exploite Balancer v2 comme fournisseur de Flash Loans sans frais.

// 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; // Calculé off-chain pour sécuriser le 1er 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();
        // Validation de la cohérence des tableaux transmis par 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];
        // Sauvegarde du solde initial AVANT l'exécution des swaps (inclut l'emprunt + solde préexistant)
        uint256 balanceBefore = IERC20(params.tokenA).balanceOf(address(this));
        // 1. Swap TokenA -> TokenB sur Uniswap v3 (protection dynamique contre le 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 sur SushiSwap (montant min en sortie = 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. Vérification de la disponibilité des fonds AVANT le remboursement
        uint256 balanceBeforeRepayment = IERC20(params.tokenA).balanceOf(address(this));
        if (balanceBeforeRepayment < repayAmount + params.minProfit) {
            revert InsufficientProfit();
        }
        // 4. Remboursement du prêt au Vault Balancer
        IERC20(params.tokenA).safeTransfer(
            address(balancerVault),
            repayAmount
        );
        // 5. Validation de la conservation du profit net en tenant compte du solde historique
        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);
    }
}

Pour des tokens ERC-20 standards, l'enchaînement des swaps et du remboursement s'avère parfaitement cohérent dans ce code. Avant tout déploiement en production, pensez à valider les adresses des routeurs sur le réseau cible, les frais effectifs de Balancer ainsi que la valeur de minAmountOutV3 adaptée à vos transactions.

6. Protection contre l'interception d'ordres (Protection MEV)

Transmettre une opportunité d'arbitrage via des endpoints RPC publics (Infura, Alchemy) vers le mempool public revient à offrir vos gains aux bots de type sandwich. Les searchers scrutent en permanence le mempool pour appliquer des stratégies de Front-running ou Back-running. hooks

La solution : passer par un RPC privé (Flashbots Builder)

  1. Acheminez vos transactions directement vers les builders de blocks (Flashbots, Beaverbuild, Titan) en passant par des endpoints privés (https://rpc.flashbots.net).
  2. Côté infrastructure (Node.js / Python), privilégiez l'envoi de bundles (eth_sendBundle) pour n'accorder un pourboire au mineur/valideur (Priority Fee) qu'en cas d'exécution réussie de votre transaction.

7. Interaction avec l'infrastructure MEV via Flashbots (Node.js / Viem)

Envoyer des transactions d'arbitrage de manière publique via un eth_sendTransaction standard en 2026, c'est la garantie absolue de se faire dépouiller par les bots MEV. Le seul moyen de sécuriser votre trade reste l'envoi d'un bundle atomique directement aux builders de blocs (Flashbots, Titan, Beaverbuild, BuilderNet).

Si les conditions sur le DEX évoluent et que le spread se résorbe avant l'inclusion dans le bloc, le script de simulation rejettera le bundle et le builder ne l'intégrera tout simplement pas au bloc. Résultat : vous ne payez pas le moindre 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] Variables d\'environnement obligatoires non définies');
}
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
);
// Traitement séquentiel des bundles au sein d'un même processus 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] Adresse du contrat d\'arbitrage invalide');
    }
    if (
        typeof calldata !== 'string' ||
        !utils.isHexString(calldata)
    ) {
        throw new Error('[Flashbots] Calldata invalide');
    }
    if (
        !Number.isSafeInteger(targetBlockNumber) ||
        targetBlockNumber < 1
    ) {
        throw new Error('[Flashbots] Numéro de bloc cible invalide');
    }
    const network = await provider.getNetwork();
    if (network.chainId !== CHAIN_ID) {
        throw new Error(
            `[Flashbots] Réseau incorrect : ${network.chainId} ; Mainnet attendu (${CHAIN_ID})`
        );
    }
    const flashbotsProvider = await FlashbotsBundleProvider.create(
        provider,
        authSigner,
        RELAY_URL,
        CHAIN_ID
    );
    const latestBlock = await provider.getBlockNumber();
    if (targetBlockNumber <= latestBlock) {
        console.warn(
            `[Flashbots] Bloc cible ${targetBlockNumber} déjà dépassé`
        );
        return;
    }
    // Synchronisation du nonce avec le RPC public.
    // La file d'attente ne protège que cette instance 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
    };
    // Première simulation avec la limite de gas initiale.
    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] Aucun résultat de transaction valide'
        );
        return;
    }
    const txGasUsed = utils.BigNumber.from(firstResult.gasUsed);
    if (txGasUsed.lte(0)) {
        console.error('[Simulation Error] Consommation de gas invalide');
        return;
    }
    // Majoration de la limite de gas de 20 %.
    const adjustedGasLimit = txGasUsed
        .mul(GAS_BUFFER_PERCENT)
        .div(100);
    if (adjustedGasLimit.gt(INITIAL_GAS_LIMIT)) {
        console.error(
            `[Gas Limit] La limite calculée ${adjustedGasLimit.toString()} dépasse la limite initiale de ${INITIAL_GAS_LIMIT}`
        );
        return;
    }
    transaction.gasLimit = adjustedGasLimit;
    // Re-signature après ajustement de gasLimit.
    signedBundle = await flashbotsProvider.signBundle([
        {
            signer: executorWallet,
            transaction
        }
    ]);
    // Seconde simulation de la transaction signée.
    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] Échec de la vérification de la transaction'
        );
        return;
    }
    // Vérification que le bloc n'a pas encore été miné.
    const blockBeforeSubmission = await provider.getBlockNumber();
    if (targetBlockNumber <= blockBeforeSubmission) {
        console.warn(
            `[Flashbots] Bloc ${targetBlockNumber} déjà dépassé après la simulation`
        );
        return;
    }
    // Envoi du bundle ciblant exclusivement le bloc spécifié.
    const submission = await flashbotsProvider.sendRawBundle(
        signedBundle,
        targetBlockNumber
    );
    if ('error' in submission) {
        console.error(
            `[Submission Error] ${submission.error.message}`
        );
        return;
    }
    console.log(
        `[Flashbots] Bundle envoyé pour le bloc ${targetBlockNumber}; nonce=${nonce}; gasLimit=${adjustedGasLimit.toString()}`
    );
    const resolution = await submission.wait();
    switch (resolution) {
        case FlashbotsBundleResolution.BundleIncluded:
            console.log(
                `[Success] Bundle inclus dans le bloc ${targetBlockNumber}`
            );
            break;
        case FlashbotsBundleResolution.BlockPassedWithoutInclusion:
            console.log(
                `[Missed] Bloc ${targetBlockNumber} produit sans le bundle`
            );
            break;
        case FlashbotsBundleResolution.AccountNonceTooHigh:
            console.error(
                '[Nonce Error] Le nonce de l\'exécuteur est supérieur à celui attendu'
            );
            break;
        default:
            console.warn(
                `[Flashbots] Résultat d'exécution : ${resolution}`
            );
    }
}

8. Architecture de monitoring off-chain et détection des spreads

Attendre les mises à jour via du polling (setInterval ou API REST) vous colle une latence de 500 à 1500 ms dans la vue, ce qui réduit à néant vos chances d'exécuter un arbitrage. Les architectures de production reposent sur des pipelines réactifs taillés pour la performance en Node.js, Go ou Rust.

Bot Architectural Pipeline

Composants clés du bot

  • 1. In-Memory State unifié : Stockage des réserves actuelles (pour Uniswap v2) et des vecteurs de ticks (pour Uniswap v3) directement dans la mémoire du processus Node.js/Rust.
  • 2. Abonnement aux logs de pools : Connexion via WebSocket aux événements Sync (v2) et Swap (v3).
  • 3. Recalcul instantané : À la réception d'un nouveau log, seule la paire impactée par le changement est recalculée, sans avoir à spammer de requêtes l'ensemble des contrats.

// 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();
});

Lancement : 

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

Spécifier POOLS_JSON='[]' signifie que le bot écoutera uniquement les pools créés après son démarrage. Pour surveiller des pools déjà existants, passez simplement leurs adresses et leurs versions via la variable POOLS_JSON.

9. Check-list d'optimisation du gas (Gas Optimization)

Sur de la logique métier complexe à l'intérieur d'un smart contract, c'est la transaction la plus sobre en units of gas qui gagne la course. Chaque tranche de 10 000 gas économisée vous permet de pousser un Priority Fee plus agressif et de remporter la Priority Gas Auction (PGA).

  • Privilégiez les Custom Errors au require : Les structures require(condition, "error string") crament au bas mot 100 à 200 gas supplémentaires rien que pour stocker la chaîne de caractères. Basculer sur du if (!condition) revert InsufficientProfit() s'avère nettement moins cher.
  • Exploitez les variables immutable et constant : Les adresses de routers, du Balancer Vault et des tokens de base doivent être déclarées en immutable. Le compilateur va directement inliner les valeurs dans le bytecode, vous évitant des lectures ultra-coûteuses en storage (un SLOAD, c'est 2 100 gas dans la vue).
  • Faites des transferts directs plutôt que du transferFrom : Si votre contrat manipule des tokens intermédiaires, balancez-les directement à l'adresse du pool suivant via un simple transfer. Ça évite de faire tourner des paires d'appels approve / transferFrom parfaitement inutiles.
  • Transient Storage (TSTORE / TLOAD via l'EIP-1153) :

    Sur les réseaux qui supportent l'EIP-1153, passez votre guard anti-réentrabilité sur du stockage transitoire. Vous faites passer le coût d'écriture de l'état de 20 000 gas à seulement 100 gas.

10. Guide de déploiement et de test étape par étape

  • Étape 1. Fork du Mainnet (Foundry / Hardhat)

    Avant de balancer le contrat en prod, il faut impérativement simuler votre stratégie sur un fork local du Mainnet en embarquant l'état réel et à jour de tous les pools.

    # Lancement d'un nœud fork local avec Anvil (Foundry)
    anvil --fork-url https://eth-mainnet.g.alchemy.com/v2/YOUR_API_KEY --fork-block-number 19800000
  • Étape 2. Déploiement du contrat sur le fork local

    Déployez le smart contract FlashArbEngine en lui injectant les vraies adresses de l'infrastructure :

    • Balancer Vault v2 : 0xBA12222222228d8Ba445958a75a0704d566BF2C8
    • Uniswap v3 SwapRouter02 : 0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45
    • Sushiswap V2 Router : 0xd9e1cE17f2641f24aE83637ab66a2cca9C378804
  • Étape 3. Simulation du spread de prix

    Via un script de simulation, balancez un gros market sell de WETH sur le pool Sushiswap pour provoquer artificiellement un déséquilibre de prix et décaler la liquidité.

  • Étape 4. Appel à executeArbitrage

    Déclenchez la fonction de votre contrat exécuteur en fournissant les paramètres précis de l'emprunt ciblait. Vérifiez que la transaction passe nickel, que le principal du flash loan est rendu à Balancer et que le profit net reste bien au chaud sur le solde de FlashArbEngine.

Faire de l'arbitrage par Flash Loan en conditions réelles exige de combiner du dev low-level sur Solidity, une fine compréhension de la tuyauterie DeFi et un setup d'infrastructure off-chain taillé pour le MEV. Un smart contract bien optimisé exploitant les prêts sans frais de Balancer v2 couplé à des relais de transactions privés constitue une base ultra-solide pour monter un bot de trading 100 % automatisé.

Résumer cet article de blog avec :

FAQ

L arbitrage flash loan repose sur des transactions atomiques exécutées dans un seul bloc EVM où des liquidités sont empruntées sans garantie, échangées entre des pools DEX présentant des écarts de prix, puis remboursées avec commission. Si le solde final ne couvre pas le prêt, l EVM annule l opération par un revert et seul le coût du gas est perdu.

La sécurisation contre le front running et les attaques sandwich s effectue en contournant totalement le mempool public. Les transactions signées sont transmises directement aux constructeurs de blocs via des relais RPC privés comme Flashbots pour garantir une exécution atomique et invisible.

Le contrat intelligent valide le profit en effectuant une double vérification des soldes dans la fonction de callback pour s assurer que le gain net dépasse le seuil minimum avant de rembourser le vault. Le slippage est contrôlé en passant des paramètres stricts de sortie minimale aux routeurs des 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

...

Partager votre avis

Votre adresse e-mail ne sera pas publiée. Les champs obligatoires sont indiqués *