Naciśnij ESC, aby zamknąć

Arbitraż Flash Loan krok po kroku: DEX i Solidity

Różnice w cenach tej samej pary tokenów w pulach Uniswap v3 i Sushiswap pojawiają się regularnie. Najczęściej dzieje się to w momentach wysokiej zmienności rynkowej, gdy spore zlecenie "przepycha" cenę w jednej puli, podczas gdy druga nie zdążyła jeszcze zareagować. Flash Loan (pożyczka błyskawiczna) pozwala zgarnąć tę różnicę bez posiadania własnego kapitału na realizację transakcji.

Flash loan to nic innego jak atomowa transakcja w EVM: pożyczasz $1 000 000 z protokołu lendingowego (Aave, Balancer, Uniswap), kupujesz taniej na DEX A, sprzedajesz drożej na DEX B, zwracasz kapitał pożyczki wraz z prowizją i zgarniasz czysty zysk. Jeśli na którymkolwiek etapie w łańcuchu wywołań coś pójdzie nie tak lub końcowy balans okaże się niższy niż kwota do zwrotu, EVM cofa (revert) całą transakcję. Jedyne, co tracisz, to opłata za gas.

1. Mechanika rynkowa i ukryte ryzyka „bezzwrotnego” arbitrażu

Określenie „bezryzykowny” odnosi się wyłącznie do braku ryzyka rynkowego (directional risk) oraz braku ryzyka utraty własnego depozytu. W rzeczywistości jednak trader musi zmierzyć się z kilkoma innymi krytycznymi czynnikami.

Atomic Transaction
 

Główne pułapki

  • Slippage (Poślizg cenowy): Im większy wolumen flash loana, tym mocniej Twoja własna transakcja przesuwa cenę w puli (price impact). Jeśli przesadzisz z rozmiarem pożyczki, cały spread zostanie zjedzony przez poślizg.
  • MEV i Front-running: Twoje transakcje w publicznym mempoolu są błyskawicznie wyłapywane przez boty wyszukujące (searchers). Mogą one zgarnąć okazję sprzed nosa, wciskając swój swap z wyższą opłatą za gas (Priority Fee).
  • Koszty gazu (Gas Overhead): Wywołanie skomplikowanego smart kontraktu z kilkoma zewnętrznymi swapami pożera od 250 000 do 450 000 jednostek gazu. Na Ethereum L1 przy gazie na poziomie 30-50 Gwei koszty transakcji potrafią bez problemu zjeść mikrosptready.

2. Ekonomia transakcji i matematyka wyliczania wielkości pożyczki

Aby arbitraż był zyskowny, musisz precyzyjnie wyliczyć wielkość pożyczki x, przy której zysk netto osiąga maksimum.

W pulach Constant Product (x · y = k, np. Uniswap v2) cena zależy od rezerw Rx oraz Ry. Wykonując swap na kwotę Δx, kupujący otrzymuje:

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

gdzie γ to opłata puli (np. 0.003 dla 0.3%).

Model finansowy zysku

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

Prowizje za pożyczki błyskawiczne u kluczowych dostawców:

  • Balancer v2: 0%
  • Uniswap v3 (Flash Swaps): zależy od tieru puli (0.05%, 0.3%, 1.0%)
  • Aave v3: 0.05%

3. Porównanie dostawców płynności

DostawcaProwizja za pożyczkęKoszty gazuWarunki szczególne
Balancer v20.00%NiskieWymaga pojedynczego punktu wejścia przez Vault, idealny na start
Aave v30.05%ŚrednieWysoka płynność, wsparcie dla dziesiątek sieci (Polygon, Arbitrum, Optimism)
Uniswap v30.05% – 0.30%WysokiePozwala na Flash Swap bezpośrednio z puli, bez angażowania zewnętrznych protokołów lendingowych

4. Algorytm realizacji arbitrażu krok po kroku

  1. Ocena spreadu: Monitoring różnicy cen między DEX A (niska cena) a DEX B (wysoka cena) przez połączenie WebSocket z węzłem lub strumienie gRPC.
  2. Kalkulacja zysku: Wyliczenie optymalnej wielkości x z uwzględnieniem prowizji pul, opłat Balancer/Aave oraz aktualnego Base Fee + Priority Fee.
  3. Inicjacja transakcji: Kontrakt żąda Flash Loana z Balancer Vault.
  4. Odbiór środków: Vault wysyła wnioskowane tokeny do kontraktu arbitrażowego i wywołuje funkcję callback receiveFlashLoan.
  5. Swap 1: Kontrakt wysyła pozyskany token do DEX A (Uniswap v3), otrzymując w zamian docelowy aktyw.
  6. Swap 2: Kontrakt wysyła otrzymany aktyw docelowy do DEX B (Sushiswap/Uniswap v2), wymieniając go z powrotem na token początkowy.
  7. Zwrot pożyczki: Kontrakt udziela zatwierdzenia (approve) lub bezpośrednio przelewa kwotę pożyczki do Balancer Vault.
  8. Weryfikacja zysku: Kontrakt sprawdza, czy ostateczny balans jest wyższy od początkowego. Jeśli nie — rzuca revert.

5. Gotowy smart kontrakt w Solidity (OpenZeppelin + Balancer v2)

Oto kompletny kontrakt napisany dla sieci EVM. Wykorzystuje Balancer v2 jako dostawcę darmowych pożyczek błyskawicznych (zero fee).

// 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; // Wyliczane off-chain dla ochrony 1. swapu
        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();
        // Weryfikacja poprawności struktury tablic z Balancera
        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];
        // Zapisujemy stan początkowy PRZED wykonaniem swapów (obejmuje pożyczkę + ewentualne pozostałości)
        uint256 balanceBefore = IERC20(params.tokenA).balanceOf(address(this));
        // 1. Swap TokenA -> TokenB na Uniswap v3 (dynamiczna ochrona przed poślizgiem)
        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 (ustawiamy minimalne wyjście = 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. Sprawdzamy dostępność środków PRZED spłatą pożyczki
        uint256 balanceBeforeRepayment = IERC20(params.tokenA).balanceOf(address(this));
        if (balanceBeforeRepayment < repayAmount + params.minProfit) {
            revert InsufficientProfit();
        }
        // 4. Zwracamy pożyczkę do Balancer Vault
        IERC20(params.tokenA).safeTransfer(
            address(balancerVault),
            repayAmount
        );
        // 5. Upewniamy się, że zysk netto został zachowany z uwzględnieniem wcześniejszego salda
        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);
    }
}

Dla standardowych tokenów ERC-20 sekwencja swapów oraz zwrot pożyczki w tym kodzie są w pełni spójne logicznie. Przed wdrożeniem na sieć główną należy jednak upewnić się co do poprawności adresów routerów, aktualnych prowizji Balancera oraz wyliczeń minAmountOutV3 dla konkretnych transakcji.

6. Ochrona przed przechwytywaniem transakcji (Ochrona MEV)

Wysyłanie transakcji arbitrażowych przez standardowe, publiczne węzły RPC (Infura, Alchemy) do publicznego mempoola to najprostsza droga do oddania zysku botom kanapkowym (sandwich bots). Searcherzy bezustannie skanują spływające transakcje i stosują techniki Front-runningu / Back-runningu.

Praktyczne rozwiązanie: Private RPC (Flashbots Builder)

  1. Kieruj transakcje bezpośrednio do builderów bloków (Flashbots, Beaverbuild, Titan) z wykorzystaniem prywatnych endpointów (https://rpc.flashbots.net).
  2. Jeśli budujesz w Node.js lub Pythonie, używaj wysyłania paczek transakcji (eth_sendBundle), ustawiając maksymalny napiwek dla walidatora (Priority Fee) płatny wyłącznie w przypadku sukcesu transakcji.

7. Interakcja z infrastrukturą MEV poprzez Flashbots (Node.js / Viem)

Wysyłanie transakcji arbitrażowych publicznie przez standardowe eth_sendTransaction w 2026 roku to gwarantowany przepis na stratę pieniędzy na rzecz botów MEV. Jedynym sposobem na zabezpieczenie transakcji jest wysłanie atomicznego bundle'a (paczki) bezpośrednio do builderów bloków (Flashbots, Titan, Beaverbuild, BuilderNet).

Jeśli warunki na DEX się zmienią i spread zniknie przed dołączeniem transakcji do bloku, skrypt symulacyjny po prostu odrzuci bundle'a, a builder nie włączy go do bloku. Nie przepalisz ani jednego Wei na 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] Brak wymaganych zmiennych środowiskowych');
}
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
);
// Sekwencyjne przetwarzanie bundle'i w ramach jednego procesu 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] Nieprawidłowy adres kontraktu arbitrażowego');
    }
    if (
        typeof calldata !== 'string' ||
        !utils.isHexString(calldata)
    ) {
        throw new Error('[Flashbots] Nieprawidłowy calldata');
    }
    if (
        !Number.isSafeInteger(targetBlockNumber) ||
        targetBlockNumber < 1
    ) {
        throw new Error('[Flashbots] Nieprawidłowy numer bloku docelowego');
    }
    const network = await provider.getNetwork();
    if (network.chainId !== CHAIN_ID) {
        throw new Error(
            `[Flashbots] Nieprawidłowa sieć: ${network.chainId}; oczekiwano 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] Docelowy blok ${targetBlockNumber} już minął`
        );
        return;
    }
    // Synchronizacja nonce z publicznym RPC.
    // Kolejka chroni wyłącznie tę instancję 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
    };
    // Pierwsza symulacja z początkowym limitem gasu.
    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] Brak udanego wyniku transakcji'
        );
        return;
    }
    const txGasUsed = utils.BigNumber.from(firstResult.gasUsed);
    if (txGasUsed.lte(0)) {
        console.error('[Simulation Error] Nieprawidłowe zużycie gasu');
        return;
    }
    // Zwiększenie limitu gasu o 20%.
    const adjustedGasLimit = txGasUsed
        .mul(GAS_BUFFER_PERCENT)
        .div(100);
    if (adjustedGasLimit.gt(INITIAL_GAS_LIMIT)) {
        console.error(
            `[Gas Limit] Wyliczony limit ${adjustedGasLimit.toString()} przekracza początkowy limit ${INITIAL_GAS_LIMIT}`
        );
        return;
    }
    transaction.gasLimit = adjustedGasLimit;
    // Ponowne podpisanie po zmianie gasLimit.
    signedBundle = await flashbotsProvider.signBundle([
        {
            signer: executorWallet,
            transaction
        }
    ]);
    // Ponowna symulacja podpisanej transakcji.
    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] Transakcja nie przeszła weryfikacji'
        );
        return;
    }
    // Upewniamy się, że blok nie został jeszcze wydobyty.
    const blockBeforeSubmission = await provider.getBlockNumber();
    if (targetBlockNumber <= blockBeforeSubmission) {
        console.warn(
            `[Flashbots] Blok ${targetBlockNumber} już minął po symulacji`
        );
        return;
    }
    // Wysyłamy bundle tylko do wskazanego bloku.
    const submission = await flashbotsProvider.sendRawBundle(
        signedBundle,
        targetBlockNumber
    );
    if ('error' in submission) {
        console.error(
            `[Submission Error] ${submission.error.message}`
        );
        return;
    }
    console.log(
        `[Flashbots] Bundle wysłany dla bloku ${targetBlockNumber}; nonce=${nonce}; gasLimit=${adjustedGasLimit.toString()}`
    );
    const resolution = await submission.wait();
    switch (resolution) {
        case FlashbotsBundleResolution.BundleIncluded:
            console.log(
                `[Success] Bundle dołączony do bloku ${targetBlockNumber}`
            );
            break;
        case FlashbotsBundleResolution.BlockPassedWithoutInclusion:
            console.log(
                `[Missed] Blok ${targetBlockNumber} wydobyty bez bundle'a`
            );
            break;
        case FlashbotsBundleResolution.AccountNonceTooHigh:
            console.error(
                '[Nonce Error] Nonce wykonawcy jest wyższy niż oczekiwany'
            );
            break;
        default:
            console.warn(
                `[Flashbots] Wynik egzekucji: ${resolution}`
            );
    }
}

8. Architektura monitoringu off-chain i wyszukiwania spreadów

Czekanie na aktualizacje poprzez polling (setInterval lub REST API) generuje opóźnienie rzędu 500–1500 ms, co całkowicie przekreśla szanse na udany arbitraż. W systemach produkcyjnych stosuje się architekturę reaktywną opartą na Node.js, Go lub Rust.

Bot Architectural Pipeline

Główne komponenty bota

  • 1. Spójny In-Memory State: Przechowywanie aktualnych rezerw (dla Uniswap v2) oraz wektorów tików (dla Uniswap v3) bezpośrednio w pamięci procesu Node.js/Rust.
  • 2. Subskrypcja logów puli: Połączenie przez WebSocket do zdarzeń Sync (v2) i Swap (v3).
  • 3. Błyskawiczne przeliczanie: Po odebraniu nowego loga przeliczana jest wyłącznie para, w której zaszły zmiany — bez zbędnego odpytywania wszystkich kontraktów na sieci.

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

Uruchomienie: 

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

Ustawienie POOLS_JSON='[]' sprawia, że monitor zacznie subskrybować tylko te pule, które zostaną utworzone już po jego wystartowaniu. Aby śledzić istniejące pule, wystarczy przekazać ich adresy oraz wersje bezpośrednio w zmiennej POOLS_JSON.

9. Checklist optymalizacji kosztów gasu (Gas Optimization)

W starciu złożonej logiki smart kontraktów wygrywa ten, czyja transakcja zużywa mniej jednostek gasu. Każde zaoszczędzone 10 000 gasu pozwala wrzucić wyższy Priority Fee i zgarnąć wygraną w aukcji MEV.

  • Stosuj Custom Errors zamiast Require: Klasyczne konstrukcje require(condition, "error string") przepalają minimum 100–200 dodatkowego gasu na samo przechowywanie tekstu błędu. Przejście na if (!condition) revert InsufficientProfit() wysprząta ten narzut i wyjdzie znacznie taniej.
  • Wykorzystanie zmiennych immutable oraz constant: Adresy routerów, Balancer Vaulta czy bazowych tokenów powinny mieć flagi immutable. Dzięki temu wartości zostają wpięte bezpośrednio w bytecode kontraktu, eliminując kosztowne operacje odczytu ze pamięci storage (SLOAD to aż 2100 gasu).
  • Bezpośrednie transfery zamiast transferFrom: Jeśli Twój kontrakt operuje na pośrednich tokenach, ślij je bezpośrednio na adres kolejnej puli za pomocą transfer, omijając zbędne wywołania approve / transferFrom.
  • Transient Storage (TSTORE / TLOAD z EIP-1153):

    W sieciach ze wsparciem dla EIP-1153 wykorzystuj pamięć przejściową (transient storage) do flagi reentrancy guard. Taka zmiana ścina koszt zapisu stanu zaporowych 20 000 do zaledwie 100 gasu.

10. Instrukcja wdrożenia i testowania krok po kroku

  • Krok 1. Fork sieci Mainnet (Foundry / Hardhat)

    Zanim wypuścisz kontrakt na produkcję, koniecznie przeprowadź symulację na lokalnym forku Mainnetu z zachowaniem pełnego, żywego stanu wszystkich pul.

    # Odpalenie lokalnego węzła-forka przez Anvil (Foundry)
    anvil --fork-url https://eth-mainnet.g.alchemy.com/v2/YOUR_API_KEY --fork-block-number 19800000
  • Krok 2. Deploy kontraktu na lokalnym forku

    Zrób deploy kontraktu FlashArbEngine, podając rzeczywiste adresy produkcyjnej infrastruktury:

    • Balancer Vault v2: 0xBA12222222228d8Ba445958a75a0704d566BF2C8
    • Uniswap v3 SwapRouter02: 0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45
    • Sushiswap V2 Router: 0xd9e1cE17f2641f24aE83637ab66a2cca9C378804
  • Krok 3. Symulacja rozjazdu cenowego (spreadu)

    Za pomocą skryptu symulacyjnego wykonaj potężną sprzedaż WETH w puli Sushiswap, aby ręcznie wywołać dysbalans cenowy i zwichrować płynność.

  • Krok 4. Wywołanie executeArbitrage

    Strzel do funkcji kontraktu wykonawczego, przekazując dokładne parametry pożyczki. Upewnij się, że transakcja wchodzi gładko, kapitał wraca do Balancera, a czysty zysk zostaje w kieszeni na balansie FlashArbEngine.

Praktyczny arbitraż Flash Loan wymaga połączenia niskopoziomowej deweloperki w Solidity, dogłębnej znajomości protokołów DeFi oraz sprawnej konfiguracji off-chainowej infrastruktury pod MEV. Solidny smart kontrakt z zerową prowizją za pożyczkę przez Balancer v2 w parze z prywatnymi kanałami wysyłki transakcji dają fundament pod stworzenie w pełni zautomatyzowanej maszyny do robienia zysków.

Podsumuj ten wpis na blogu za pomocą:

FAQ

Arbitraż flash loan wykorzystuje atomowe transakcje w ramach jednego bloku EVM, w których pożyczona płynność z protokołu lendingowego jest wymieniana na tańszym DEX i sprzedawana drożej na innym. Pożyczka wraz z prowizją jest automatyczna zwracana w tej samej transakcji, a brak zwrotu powoduje natychmiastowy revert.

Zabezpieczenie przed botami front-runningowymi i atakami typu sandwich wymaga całkowiteg o ominięcia publicznego mempoola. Podpisaną transakcję lub zestaw transakcji przesyła się bezpośrednio do budowniczych bloków za pośrednictwem prywatnych przekaźników RPC, takich jak Flashbots.

Ochrona przed poślizgiem cenowym opiera się na przekazywaniu dynamicznie wyliczonych parametrów minimalnego wyjścia do routerów DEX oraz dwustopniowej weryfikacji salda tokena bazowego w funkcji zwrotnej przed ostatecznym rozliczeniem pożyczki.
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

...

Dodaj opinię

Twój adres e-mail nie zostanie opublikowany. Obowiązkowe pola są oznaczone*