Drücken Sie ESC, um zu schließen

Flash Loan Arbitrage Anleitung: DEX & Solidity Code

Preisunterschiede für dasselbe Handelspaar zwischen Uniswap v3 und Sushiswap Pools sind keine Seltenheit. Sie entstehen meist in Phasen hoher Volatilität, wenn eine große Order den Preis in einem Pool nach unten drückt, während der zweite Pool noch hinterherhinkt. Ein Flash Loan (Blitzkredit) ermöglicht es dir, diese Arbitrage-Spanne mitzunehmen – ganz ohne eigenes Eigenkapital einzusetzen.

Ein Flash Loan ist eine atomare Transaktion auf der EVM: Du leihst dir $1.000.000 von einem Lending-Protokoll (Aave, Balancer, Uniswap), kaufst das Asset günstiger auf DEX A, verkaufst es teurer auf DEX B, zahlst das Darlehen samt Gebühr zurück und streichst den Reingewinn ein. Schlägt auch nur ein einziger Schritt in der Aufrufkette fehl oder reicht der Endbestand nicht für die Rückzahlung aus, rollt die EVM (revert) die komplette Transaktion zurück. Das einzige, was du verlierst, sind die Gas-Gebühren.

1. Marktmechanik und die versteckten Risiken „risikoloser“ Arbitrage

Der Begriff „risikolos“ bezieht sich ausschließlich auf das Fehlen eines Richtungsrisikos (directional risk) sowie das Risiko, das eigene Eigenkapital zu verlieren. In der Praxis sieht sich ein Trader jedoch mit ganz anderen Hürden konfrontiert.

Atomic Transaction
 

Die größten Fallstricke

  • Slippage (Preisrutsch): Je größer das Volumen des Flash Loans, desto stärker verschiebt dein eigener Trade den Preis im Pool (price impact). Übersteigt die Kreditsumme das Optimum, wird der Spread komplett von der Slippage aufgefressen.
  • MEV und Front-running: Deine Transaktionen im öffentlichen Mempool bleiben Searcher-Bots keineswegs verborgen. Sie können dir den Trade wegschnappen, indem sie ihren eigenen Swap mit einer höheren Gas-Gebühr (Priority Fee) dazwischenschieben.
  • Gas-Overhead: Der Aufruf eines komplexen Smart Contracts mit mehreren externen Swaps frisst zwischen 250.000 und 450.000 Gas-Units. Auf Ethereum L1 bei einem Gaspreis von 30–50 Gwei verbrennen die Transaktionskosten feine Mikro-Spreads im Handumdrehen.

2. Unit Economics und die Mathematik der Optimalen Kredithöhe

Für eine erfolgreiche Arbitrage musst du das exakte Kreditvolumen x berechnen, bei dem der Reingewinn sein Maximum erreicht.

In Constant-Product-Pools (x · y = k, wie Uniswap v2) hängt der Preis von den Reserven Rx und Ry ab. Bei einem Swap über den Betrag Δx erhält der Käufer:

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

wobei γ die Pool-Gebühr darstellt (z. B. 0.003 für 0.3%).

Finanzmodell für den Profit

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

Gebühren für Blitzkredite bei den wichtigsten Anbietern:

  • Balancer v2: 0%
  • Uniswap v3 (Flash Swaps): Abhängig vom Fee-Tier des Pools (0.05%, 0.3%, 1.0%)
  • Aave v3: 0.05%

3. Vergleich der Liquiditätsanbieter

AnbieterKreditgebührGas-KostenBesonderheiten
Balancer v20.00%NiedrigBenötigt einen zentralen Einstiegspunkt über den Vault; ideal für den Einstieg
Aave v30.05%MittelHohe Liquidität, Support für dutzende Netzwerke (Polygon, Arbitrum, Optimism)
Uniswap v30.05% – 0.30%HochErmöglicht Flash Swaps direkt aus dem Pool ohne externe Lending-Protokolle

4. Schritt-für-Schritt-Algorithmus zur Arbitrage-Ausführung

  1. Spread-Analyse: Überwachung der Preisdifferenz zwischen DEX A (niedriger Preis) und DEX B (hoher Preis) via WebSocket-Verbindung zu einer Node oder über gRPC-Streams.
  2. Profit-Berechnung: Ermittlung der optimalen Größe x unter Berücksichtigung der Pool-Gebühren, der Balancer/Aave-Marge und der aktuellen Base Fee + Priority Fee.
  3. Transaktionsstart: Der Contract fordert den Flash Loan beim Balancer Vault an.
  4. Mitteleingang: Der Vault schiebt die angeforderten Token in den Arbitrage-Contract und führt die Callback-Funktion receiveFlashLoan aus.
  5. Swap 1: Der Contract schickt die geliehenen Token an DEX A (Uniswap v3) und erhält im Gegenzug das Ziel-Asset.
  6. Swap 2: Der Contract leitet das erhaltene Ziel-Asset an DEX B (Sushiswap/Uniswap v2) weiter und tauscht es zurück in den Ursprungs-Token.
  7. Rückzahlung: Der Contract erteilt die Freigabe (approve) oder überweist die Kreditsumme direkt zurück an den Balancer Vault.
  8. Profit-Check: Der Contract prüft, ob der Endbestand höher ist als der Startbestand. Wenn nicht, wird ein Revert ausgelöst.

5. Fertiger Smart Contract in Solidity (OpenZeppelin + Balancer v2)

Dieser Contract ist für EVM-Netzwerke geschrieben. Er nutzt Balancer v2 als gebührenfreien Flash-Loan-Provider.

// 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; // Wird off-chain berechnet zum Schutz des 1. Swaps
        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();
        // Prüfung der Korrektheit der Array-Strukturen von 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];
        // Ursprungsbestand VOR den Swaps einfrieren (enthält den eingehenden Kredit + Altbestände)
        uint256 balanceBefore = IERC20(params.tokenA).balanceOf(address(this));
        // 1. Swap TokenA -> TokenB auf Uniswap v3 (dynamischer Slippage-Schutz)
        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 auf SushiSwap (Minimaler Output = 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. Verfügbarkeit der Mittel VOR der Kreditrückzahlung prüfen
        uint256 balanceBeforeRepayment = IERC20(params.tokenA).balanceOf(address(this));
        if (balanceBeforeRepayment < repayAmount + params.minProfit) {
            revert InsufficientProfit();
        }
        // 4. Kredit an den Balancer Vault zurückzahlen
        IERC20(params.tokenA).safeTransfer(
            address(balancerVault),
            repayAmount
        );
        // 5. Sicherstellen, dass der Reingewinn unter Berücksichtigung von Altbeständen erhalten bleibt
        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);
    }
}

Für Standard-ERC-20-Token ist die Abfolge von Swaps und Kreditrückzahlung in diesem Code logisch stimmig. Vor dem Einsatz im Mainnet sollten jedoch die Router-Adressen, die tatsächlichen Balancer-Gebühren sowie die minAmountOutV3-Werte für die konkreten Trades gründlich überprüft werden.

6. Schutz vor Front-running (MEV-Schutz)

Wer Arbitrage-Transaktionen über Standard-Public-RPCs (Infura, Alchemy) in den öffentlichen Mempool bläst, schenkt seinen Profit quasi direkt den Sandwich-Bots. Searcher scannen eingehende Trades ununterbrochen und nutzen Front-running sowie Back-running gnadenlos aus.

Praktische Lösung: Private RPC (Flashbots Builder)

  1. Sende deine Transaktionen über private Endpunkte (https://rpc.flashbots.net) direkt an Block-Builder (Flashbots, Beaverbuild, Titan).
  2. Nutze als Node.js- oder Python-Entwickler das Senden von Bundles (eth_sendBundle). So wird das Trinkgeld für den Validator (Priority Fee) überhaupt erst dann fällig, wenn der Trade auch wirklich erfolgreich durchgeht.

7. Interaktion mit der MEV-Infrastruktur über Flashbots (Node.js / Viem)

Wer Arbitrage-Transaktionen im Jahr 2026 noch öffentlich über das Standard-eth_sendTransaction rausschickt, verbrennt wegen MEV-Bots garantiert Geld. Der einzige Weg, seinen Trade abzusichern, ist das Senden eines atomaren Bundles direkt an Block Builder (Flashbots, Titan, Beaverbuild, BuilderNet).

Ändern sich die DEX-Bedingungen und der Spread bricht ein, bevor die Transaktion im Block landet, verwirft das Simulationsskript das Bundle schlichtweg – und der Builder nimmt es erst gar nicht auf. Ihr zahlt somit nicht einen einzigen Wei an Gas-Gebühren.

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] Erforderliche Umgebungsvariablen fehlen');
}
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
);
// Sequenzielle Verarbeitung der Bundles innerhalb eines einzelnen Node.js-Prozesses.
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] Ungültige Adresse des Arbitrage-Contracts');
    }
    if (
        typeof calldata !== 'string' ||
        !utils.isHexString(calldata)
    ) {
        throw new Error('[Flashbots] Ungültiges calldata');
    }
    if (
        !Number.isSafeInteger(targetBlockNumber) ||
        targetBlockNumber < 1
    ) {
        throw new Error('[Flashbots] Ungültige Zielblocknummer');
    }
    const network = await provider.getNetwork();
    if (network.chainId !== CHAIN_ID) {
        throw new Error(
            `[Flashbots] Falsches Netzwerk: ${network.chainId}; Mainnet (${CHAIN_ID}) erwartet`
        );
    }
    const flashbotsProvider = await FlashbotsBundleProvider.create(
        provider,
        authSigner,
        RELAY_URL,
        CHAIN_ID
    );
    const latestBlock = await provider.getBlockNumber();
    if (targetBlockNumber <= latestBlock) {
        console.warn(
            `[Flashbots] Zielblock ${targetBlockNumber} ist bereits verstrichen`
        );
        return;
    }
    // Nonce-Synchronisation mit dem öffentlichen RPC.
    // Die Queue schützt nur diese spezifische Node.js-Instanz.
    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
    };
    // Erste Simulation mit dem initialen Gas-Limit.
    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] Kein erfolgreiches Transaktionsergebnis vorhanden'
        );
        return;
    }
    const txGasUsed = utils.BigNumber.from(firstResult.gasUsed);
    if (txGasUsed.lte(0)) {
        console.error('[Simulation Error] Ungültiger Gasverbrauch');
        return;
    }
    // Erhöhung des Gas-Limits um 20 % als Puffer.
    const adjustedGasLimit = txGasUsed
        .mul(GAS_BUFFER_PERCENT)
        .div(100);
    if (adjustedGasLimit.gt(INITIAL_GAS_LIMIT)) {
        console.error(
            `[Gas Limit] Berechnetes Limit ${adjustedGasLimit.toString()} übersteigt das ursprüngliche Limit von ${INITIAL_GAS_LIMIT}`
        );
        return;
    }
    transaction.gasLimit = adjustedGasLimit;
    // Erneutes Signieren nach Anpassung des gasLimits.
    signedBundle = await flashbotsProvider.signBundle([
        {
            signer: executorWallet,
            transaction
        }
    ]);
    // Erneute Simulation der signierten Transaktion.
    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] Transaktionsprüfung fehlgeschlagen'
        );
        return;
    }
    // Sicherstellen, dass der Block noch nicht gebaut wurde.
    const blockBeforeSubmission = await provider.getBlockNumber();
    if (targetBlockNumber <= blockBeforeSubmission) {
        console.warn(
            `[Flashbots] Block ${targetBlockNumber} nach der Simulation bereits verstrichen`
        );
        return;
    }
    // Senden des Bundles ausschließlich für den angegebenen Block.
    const submission = await flashbotsProvider.sendRawBundle(
        signedBundle,
        targetBlockNumber
    );
    if ('error' in submission) {
        console.error(
            `[Submission Error] ${submission.error.message}`
        );
        return;
    }
    console.log(
        `[Flashbots] Bundle für Block ${targetBlockNumber} übermittelt; nonce=${nonce}; gasLimit=${adjustedGasLimit.toString()}`
    );
    const resolution = await submission.wait();
    switch (resolution) {
        case FlashbotsBundleResolution.BundleIncluded:
            console.log(
                `[Success] Bundle erfolgreich in Block ${targetBlockNumber} aufgenommen`
            );
            break;
        case FlashbotsBundleResolution.BlockPassedWithoutInclusion:
            console.log(
                `[Missed] Block ${targetBlockNumber} wurde ohne das Bundle gebaut`
            );
            break;
        case FlashbotsBundleResolution.AccountNonceTooHigh:
            console.error(
                '[Nonce Error] Nonce des Executors liegt über dem erwarteten Wert'
            );
            break;
        default:
            console.warn(
                `[Flashbots] Ausführungsergebnis: ${resolution}`
            );
    }
}

8. Off-Chain-Monitoring-Architektur und Spread-Discovery

Wer auf Updates per Polling setzt (setInterval oder REST API), fängt sich einen Latenz-Overhead von 500–1500 ms ein – was jegliche Chancen auf erfolgreiche Arbitrage komplett zunichtemacht. In Production-Systemen kommt deshalb eine reaktive Architektur auf Basis von Node.js, Go oder Rust zum Einsatz.

Bot Architectural Pipeline

Hauptkomponenten des Bots

  • 1. Einheitlicher In-Memory State: Speicherung der aktuellen Reserven (für Uniswap v2) und Vektor-Ticks (für Uniswap v3) direkt im Arbeitsspeicher des Node.js/Rust-Prozesses.
  • 2. Subskription von Pool-Logs: Anbindung via WebSocket an die Events Sync (v2) und Swap (v3).
  • 3. Sofortige Neuberechnung: Bei Erhalt eines neuen Logs wird nur das geänderte Paar neu berechnet – ganz ohne teures Polling aller Smart Contracts.

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

Start: 

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

POOLS_JSON='[]' bedeutet, dass der Monitor erst ab dem Start neu erstellte Pools beobachtet. Um bestehende Pools zu tracken, einfach deren Adressen und Versionen via POOLS_JSON übergeben.

9. Gas-Optimierungs-Checkliste (Gas Optimization)

Bei komplexer Smart-Contract-Logik gewinnt am Ende die Transaktion, die am wenigsten Gas Units frisst. Jede 10.000 eingesparte Gas-Einheit verschafft euch Spielraum für eine höhere Priority Fee, um das Bieterrennen im Mempool (PGA) zu gewinnen.

  • Custom Errors statt Require verwenden: Das klassische require(condition, "error string") verbrennt locker 100–200 Gas extra, nur um den Error-String im Speicher vorzuhalten. Der Wechsel zu if (!condition) revert InsufficientProfit() ist drastisch günstiger.
  • Nutzung von immutable und constant Variablen: Adressen von Routern, Balancer Vault und Basis-Token sollten strikt als immutable deklariert werden. Das brennt die Werte direkt in den Bytecode des Contracts ein und spart teure Speicherzugriffe auf das Storage (SLOAD — satte 2.100 Gas).
  • Direktüberweisungen statt transferFrom: Hantiert euer Contract mit Zwischen-Token, schickt diese per einfachem transfer direkt an die Adresse des nächsten Pools. Das spart überflüssige approve / transferFrom-Aufrufe.
  • Transient Storage (TSTORE / TLOAD EIP-1153):

    Nutzt auf Netzwerken mit EIP-1153-Support den flüchtigen Speicher für Reentrancy-Guards. Das drückt die Kosten für das Schreiben des Status von 20.000 auf schlappe 100 Gas herunter.

10. Schritt-für-Schritt-Anleitung für Deployment und Testing

  • Schritt 1. Mainnet-Fork aufsetzen (Foundry / Hardhat)

    Bevor der Contract ins echte Netz geht, führt kein Weg an einer Simulation auf einem lokalen Mainnet-Fork vorbei – inklusive des echten Zustands aller aktuellen Pools.

    # Startet einen lokalen Fork-Node via Anvil (Foundry)
    anvil --fork-url https://eth-mainnet.g.alchemy.com/v2/YOUR_API_KEY --fork-block-number 19800000
  • Schritt 2. Contract auf dem lokalen Fork deployen

    Deployt den FlashArbEngine-Contract unter Angabe der echten Infrastruktur-Adressen:

    • Balancer Vault v2: 0xBA12222222228d8Ba445958a75a0704d566BF2C8
    • Uniswap v3 SwapRouter02: 0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45
    • Sushiswap V2 Router: 0xd9e1cE17f2641f24aE83637ab66a2cca9C378804
  • Schritt 3. Price Spread simulieren

    Führt per Simulationsscript einen fetten WETH-Market-Sell auf Sushiswap aus, um manuell ein Preisungleichgewicht und einen Liquiditäts-Shift zu erzeugen.

  • Schritt 4. executeArbitrage aufrufen

    Triggert die Funktion des Ausführungs-Contracts mit den exakten Parametern für das Flash Loan. Überprüft, ob die Transaktion sauber durchläuft, die Kreditsumme an Balancer zurückgezahlt wird und der Reingewinn auf der Adresse des FlashArbEngine landet.

Praktische Flash-Loan-Arbitrage erfordert ein solides Zusammenspiel aus Low-Level Solidity-Development, tiefem DeFi-Protokollverständnis und einem hochoptimierten Off-Chain-Setup für MEV. Ein schlanker Smart Contract mit gebührenfreien Balancer v2 Loans kombiniert mit privaten Transaktions-Relays bietet euch die perfekte Basis für ein vollautomatisches Trading-System.

Diesen Blogbeitrag zusammenfassen mit:

FAQ

Flash-Loan-Arbitrage nutzt unbesicherte atomare Transaktionen innerhalb eines einzigen EVM-Blocks, bei denen Liquidität geliehen, über DEX-Pools mit Preisunterschieden gehandelt und samt Gebühren zurückgezahlt wird. Reicht das Guthaben am Ende nicht aus, bricht die EVM die Transaktion automatisch per Revert ab, sodass lediglich die Gas-Kosten anfallen.

Der Schutz vor Front-Running und Sandwich-Angriffen erfordert die vollständige Umgehung des öffentlichen Mempools. Signierte Transaktionsbündel werden über private RPC-Relays wie Flashbots direkt an Block-Builder übermittelt, um eine atomare Ausführung ohne öffentliche Sichtbarkeit zu gewährleisten.

Die Gewinnvalidierung erfolgt über eine zweistufige Saldenprüfung in der Callback-Funktion, um sicherzustellen, dass der Nettogewinn den Mindestschwellenwert vor der Rückzahlung übersteigt. Slippage-Schutz wird durch die Übergabe dynamischer Mindestausgabeparameter an die AMM-Router-Aufrufe erzwungen.
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

...

Diskussion beitreten

Ihre E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind markiert *