اضغط على ESC للإغلاق

شرح أربيتراج Flash Loan على DEX: دليل عملي وكود

تفاوت الأسعار لنفس الزوج بين أحواض Uniswap v3 وSushiswap أمر بيصير بانتظام. عادةً يظهر هذا الشيء خلال فترات التذبذب العالي (Volatility)، لما أمر ضخم يضغط السعر في حوض معين بينما الحوض الثاني للحين ما لحّق عليه. القرض السريع (Flash Loan) يتيح لك اقتناص هذا الفارق السعري بدون ما تحتاج لرأس مال خاص فيك لإتمام الصفقة.

القرض السريع هو معاملة ذرية (Atomic Transaction) على الـ EVM: تقترض $1,000,000 من بروتوكول إقراض مثل (Aave, Balancer, Uniswap)، تشتري الأصل بسعر أرخص من DEX A، وتبيعه بسعر أعلى على DEX B، ثم ترجع أصل القرض مع العمولات وتأخذ الربح الصافي لجيبك. إذا حدث أي خلل في أي مرحلة من سلسلة الاستدعاءات أو كان الرصيد النهائي أقل من المبلغ المطلوب سداده، فالـ EVM بيسوي إلغاء فوري (revert) للمعاملة كاملة. الخسارة الوحيدة بتكون رسوم الغاز (Gas) فقط.

1. ميكانيكية السوق والمخاطر الخفية للمراجحة "خالية المخاطر"

مصطلح "خالي من المخاطر" ينطبق حصراً على عدم وجود مخاطر الاحتفاظ بالمركز (directional risk) ومخاطر خسارة رأس المال الأساسي. لكن على أرض الواقع، يتصادم المتداول مع عوامل حاسمة أخرى.

Atomic Transaction
 

أبرز الفخاخ والمطبات

  • الانزلاق السعري (Slippage): كلما زاد حجم القرض السريع، كلما تحرك السعر بسبب صفقتك أنت داخل الحوض (Price Impact). إذا تجاوز حجم القرض الحد الأمثل، فإن الفارق السعري ينتهي بالكامل بسبب الانزلاق.
  • هجمات MEV والـ Front-running: معاملاتك في الـ Mempool العام مرئية لـ بوتات البحث (Searchers). يقدرون يقتنصون الفرصة منك عن طريق إدخال صفقة التبادل الخاصة بهم برسوم غاز أعلى (Priority Fee).
  • تكلفة الغاز (Gas Overhead): استدعاء عقد ذكي معقد ينفذ عدة عمليات تبادل خارجية يستهلك من 250,000 إلى 450,000 وحدة غاز. على شبكة Ethereum L1 لما يكون الغاز بين 30-50 Gwei، تكاليف العملية بسهولة تلتهم الفوارق السعرية الدقيقة.

2. اقتصاديات الصفقة ورياضيات حساب حجم القرض

لتنفيذ المراجحة بنجاح، يجب حساب حجم القرض x بدقة عالية للوصول لأقصى ربح صافي.

في أحواض Constant Product (x · y = k، مثل Uniswap v2) يتوقف السعر على الاحتياطيات Rx و Ry. عند إجراء تبادل بمبلغ Δx، يحصل المشتري على:

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

حيث γ تمثل عمولة الحوض (مثلاً 0.003 لنسبة 0.3%).

النموذج المالي للربح

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

عمولات القروض السريعة عند كبار المزودين:

  • Balancer v2: 0%
  • Uniswap v3 (Flash Swaps): تعتمد على فئة الحوض (0.05%, 0.3%, 1.0%)
  • Aave v3: 0.05%

3. مقارنة مزودي السيولة

المزودرسوم القرضتكاليف الغازشروط خاصة
Balancer v20.00%منخفضةيتطلب نقطة دخول واحدة عبر الـ Vault، خيار مثالي للبداية
Aave v30.05%متوسطةسيولة عالية جداً، ودعم لعشرات الشبكات مثل (Polygon, Arbitrum, Optimism)
Uniswap v30.05% – 0.30%عاليةيتيح عمل Flash Swap مباشرة من الحوض بدون الحاجة لبروتوكول إقراض خارجي

4. خوارزمية تنفيذ المراجحة خطوة بخطوة

  1. تقييم الفارق السعري (Spread): مراقبة فارق السعر بين DEX A (سعر منخفض) و DEX B (سعر مرتفع) عبر اتصال WebSocket بالنود أو عبر تدفقات gRPC.
  2. حساب الربح: احتساب الحجم الأمثل x مع أخذ عمولات الأحواض، ونسبة Balancer/Aave، ورسوم Base Fee + Priority Fee الحالية بعين الاعتبار.
  3. إطلاق المعاملة: يطلب العقد قريضاً سريعاً (Flash Loan) من Balancer Vault.
  4. استلام الأموال: يرسل الـ Vault الرموز المطلوبة إلى عقد المراجحة ويستدعي دالة Callback المسماة receiveFlashLoan.
  5. التبادل الأول (Swap 1): يرسل العقد الرمز المستلم إلى DEX A (Uniswap v3) ويحصل بالمقابل على الأصل المستهدف.
  6. التبادل الثاني (Swap 2): يرسل العقد الأصل المستهدف إلى DEX B (Sushiswap/Uniswap v2) ويستبدله مجدداً بالرمز الأصلي.
  7. سداد القرض: يقوم العقد بعمل موافقة (approve) أو تحويل مباشر لمبلغ أصل القرض إلى Balancer Vault.
  8. التحقق من الربحية: يتحقق العقد من أن الرصيد النهائي أكبر من الرصيد الأولي. إذا لم يتحقق ذلك، يتم تنفيذ revert.

5. عقد ذكي جاهز بـ Solidity (OpenZeppelin + Balancer v2)

تم تنفيذ هذا العقد لشبكات EVM. وهو يستخدم Balancer v2 كمزود لخدمة القروض السريعة بدون أي عمولة.

// 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; // Рассчитывается офчейн для защиты 1-го свопа
        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();
        // Проверка корректности структуры массивов от 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];
        // Фиксируем исходный баланс ДО вызовов свопов (включает входящий займ + старые остатки)
        uint256 balanceBefore = IERC20(params.tokenA).balanceOf(address(this));
        // 1. Свап TokenA -> TokenB на Uniswap v3 (динамическая защита от проскальзывания)
        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. Свап TokenB -> TokenA на SushiSwap (задаем минимальный выход = 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. Проверяем наличие средств ДО возврата займа
        uint256 balanceBeforeRepayment = IERC20(params.tokenA).balanceOf(address(this));
        if (balanceBeforeRepayment < repayAmount + params.minProfit) {
            revert InsufficientProfit();
        }
        // 4. Возвращаем займ Balancer Vault
        IERC20(params.tokenA).safeTransfer(
            address(balancerVault),
            repayAmount
        );
        // 5. Проверяем, что чистая прибыль сделки сохранена с учётом исторического остатка
        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);
    }
}

بالنسبة لرموز ERC-20 القياسية، فإن تسلسل التبادل وسداد القرض متوافق منطقياً في هذا الكود. قبل النشر على الشبكة الرئيسية، ينبغي التأكد من صحة عناوين الروترات، والعمولة الحقيقية لـ Balancer، وقيم minAmountOutV3 الخاصة بالصفقات المحددة.

6. الحماية من اعتراض الصفقات (حماية MEV)

إرسال معاملات المراجحة عبر نقاط Public RPC العادية مثل (Infura, Alchemy) إلى الـ Mempool العام يعتبر إهداءً صريحاً للأرباح لبوتات الساندويش (Sandwich Bots). حيث يراقب الباحثون (Searchers) المعاملات القادمة ويستخدمون تقنيات Front-running و Back-running.

الحل العملي: استخدام Private RPC (Flashbots Builder)

  1. أرسل معاملتك مباشرة إلى منشئي الكتل (Block Builders) مثل (Flashbots, Beaverbuild, Titan) عبر نقاط نهاية خاصة (https://rpc.flashbots.net).
  2. بالنسبة للمطورين الذين يستخدمون Node.js / Python، يفضل استخدام نظام الحزم (eth_sendBundle)، وتحديد الحد الأقصى لإكرامية المعدّن (Priority Fee) بحيث لا تُدفع إلا في حالة نجاح الصفقة فقط.

7. التعامل مع بنيوية الـ MEV عبر Flashbots (Node.js / Viem)

إرسال معاملات المراجحة (Arbitrage) بشكل علني عبر eth_sendTransaction القياسي في عام 2026 يعرضك لخسائر محتمة بسبب بوتات الـ MEV. الطريقة الوحيدة لحماية صفقتك هي إرسال حزمة ذرية (Bundle) مباشرة إلى منشئي الكتل (Flashbots، Titan، Beaverbuild، BuilderNet).

إذا تغيرت ظروف منصة DEX وتلاشى الفارق السعري (Spread) قبل تضمين المعاملة في الكتلة، فسيقوم سكربت المحاكاة بإلغاء الحزمة، ولن يدرجها المنشئ في الكتلة إطلاقاً. لن تخسر Wei واحد على الرسوم (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] لم يتم تحديد متغيرات البيئة المطلوبة');
}
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
);
// معالجة الحزم بشكل تسلسلي داخل عملية 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] عنوان عقد المراجحة غير صحيح');
    }
    if (
        typeof calldata !== 'string' ||
        !utils.isHexString(calldata)
    ) {
        throw new Error('[Flashbots] calldata غير صحيح');
    }
    if (
        !Number.isSafeInteger(targetBlockNumber) ||
        targetBlockNumber < 1
    ) {
        throw new Error('[Flashbots] رقم الكتلة المستهدفة غير صحيح');
    }
    const network = await provider.getNetwork();
    if (network.chainId !== CHAIN_ID) {
        throw new Error(
            `[Flashbots] شبكة خاطئة: ${network.chainId}؛ المتوقع 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] الكتلة المستهدفة ${targetBlockNumber} قد تم تجاوزها بالفعل`
        );
        return;
    }
    // مزامنة الـ nonce مع RPC العام.
    // الطابور يحمي فقط هذه النسخة من 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
    };
    // المحاكاة الأولى باستخدام حد الغاز الأولي.
    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] نتيجة المعاملة الناجحة مفقودة'
        );
        return;
    }
    const txGasUsed = utils.BigNumber.from(firstResult.gasUsed);
    if (txGasUsed.lte(0)) {
        console.error('[Simulation Error] استهلاك الغاز غير صحيح');
        return;
    }
    // زيادة حد الغاز بنسبة 20%.
    const adjustedGasLimit = txGasUsed
        .mul(GAS_BUFFER_PERCENT)
        .div(100);
    if (adjustedGasLimit.gt(INITIAL_GAS_LIMIT)) {
        console.error(
            `[Gas Limit] الحد المحسوب ${adjustedGasLimit.toString()} يتجاوز الحد الأولي ${INITIAL_GAS_LIMIT}`
        );
        return;
    }
    transaction.gasLimit = adjustedGasLimit;
    // إعادة التوقيع بعد تعديل gasLimit.
    signedBundle = await flashbotsProvider.signBundle([
        {
            signer: executorWallet,
            transaction
        }
    ]);
    // إعادة محاكاة المعاملة الموقعة.
    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] لم تتجاوز المعاملة أمان التحقق'
        );
        return;
    }
    // التأكد من أن الكتلة لم تنشأ بعد.
    const blockBeforeSubmission = await provider.getBlockNumber();
    if (targetBlockNumber <= blockBeforeSubmission) {
        console.warn(
            `[Flashbots] الكتلة ${targetBlockNumber} تم تجاوزها بالفعل بعد المحاكاة`
        );
        return;
    }
    // إرسال الحزمة فقط للكتلة المحددة.
    const submission = await flashbotsProvider.sendRawBundle(
        signedBundle,
        targetBlockNumber
    );
    if ('error' in submission) {
        console.error(
            `[Submission Error] ${submission.error.message}`
        );
        return;
    }
    console.log(
        `[Flashbots] تم إرسال الحزمة للكتلة ${targetBlockNumber}؛ nonce=${nonce}؛ gasLimit=${adjustedGasLimit.toString()}`
    );
    const resolution = await submission.wait();
    switch (resolution) {
        case FlashbotsBundleResolution.BundleIncluded:
            console.log(
                `[Success] تم تضمين الحزمة في الكتلة ${targetBlockNumber}`
            );
            break;
        case FlashbotsBundleResolution.BlockPassedWithoutInclusion:
            console.log(
                `[Missed] تم إنشاء الكتلة ${targetBlockNumber} بدون تضمين الحزمة`
            );
            break;
        case FlashbotsBundleResolution.AccountNonceTooHigh:
            console.error(
                '[Nonce Error] الـ nonce الخاص بالمنفذ أعلى من المتوقع'
            );
            break;
        default:
            console.warn(
                `[Flashbots] نتيجة التنفيذ: ${resolution}`
            );
    }
}

8. معمارية المراقبة خارج السلسلة (Off-chain) واقتناص الفوارق السعرية

انتظار التحديثات عبر الـ Polling (مثل setInterval أو REST API) يسبب تأخيراً زمنياً (Latency) قدره 500–1500 ميلي ثانية، مما يقضي تماماً على أي فرصة لمراجحة ناجحة. تُبنى الأنظمة الاحترافية (Production) اعتماداً على معمارية تفاعلية بـ Node.js أو Go أو Rust.

Bot Architectural Pipeline

المكونات الرئيسية للبوت

  • 1. حالة موحدة في الذاكرة (In-Memory State): تخزين الاحتياطيات الحالية (لـ Uniswap v2) وتكتات المتجهات (لـ Uniswap v3) مباشرة في ذاكرة عملية Node.js/Rust.
  • 2. الاشتراك في سجلات المجمعات (Pool Logs): الاتصال عبر WebSocket بأحداث Sync (v2) و Swap (v3).
  • 3. إعادة حساب فورية: عند استلام سجل جديد، يتم إعادة حساب الزوج المتأثر بالتغييرات فقط، دون الحاجة لعمل Polling كامل لكل العقود.

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

طريقة التشغيل: 

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

القيمة POOLS_JSON='[]' تعني أن المراقبة ستبدأ في الاشتراك بالمجمعات التي يتم إنشاؤها بعد التشغيل. ولمراقبة المجمعات الموجودة مسبقاً، قم بتمرير عناوينها وإصداراتها عبر متغير POOLS_JSON.

9. قائمة التحقق لتحسين استهلاك الغاز (Gas Optimization)

عند تنفيذ منطق معقد للعقود الذكية (Smart Contracts)، الفائز هو من تستهلك معاملته (Transaction) وحدات غاز أقل. كل 10,000 وحدة غاز يتم توفيرها تمنحك مرونة لرفع رسوم الأولوية (Priority Fee) وكسب سباق الميمبول (Mempool PGA).

  • استخدام Custom Errors بدلاً من Require: الجمل التقليدية مثل require(condition, "error string") تحرق ما لا يقل عن 100-200 وحدة غاز إضافية فقط لتخزين نص الخطأ. الانتقال إلى if (!condition) revert InsufficientProfit() أرخص بكثير على الشبكة.
  • الاستفادة من المتغيرات immutable و constant: عناوين الروترات (Routers)، و Balancer Vault، والتوكنات الأساسية يجب أن تُعرف باستخدام واصف immutable. هذا يقوم بـ "حفر" القيم مباشرة داخل بايتكود العقد (Bytecode)، مما يلغي عمليات قراءة التخزين المكلفة (SLOAD التي تستهلك 2,100 غاز).
  • التحويل المباشر بدلاً من transferFrom: إذا كان عقدك يتعامل مع توكنات وسيطة، قم بتحويلها مباشرة إلى عنوان المجمع التالي باستخدام transfer، متجنباً استدعاءات approve / transferFrom الزائدة.
  • التخزين المؤقت (Transient Storage - TSTORE / TLOAD EIP-1153):

    في الشبكات التي تدعم EIP-1153، استخدم التخزين المؤقت لحماية إعادة الدخول (Reentrancy Guard). هذا يخفض تكلفة كتابة الحالة من 20,000 إلى 100 غاز فقط.

10. الدليل خطوة بخطوة للـ Deployment والاختبار

  • الخطوة 1: عمل Fork لشبكة Mainnet (Foundry / Hardhat)

    قبل نشر العقد على الشبكة الحقيقية، لا بد من إجراء محاكاة على Fork محلي للـ Mainnet مع الاحتفاظ بكل حالات المجمعات الحالية والواقعية.

    # تشغيل عقدة Fork محلية عبر Anvil (Foundry)
    anvil --fork-url https://eth-mainnet.g.alchemy.com/v2/YOUR_API_KEY --fork-block-number 19800000
  • الخطوة 2: نشر العقد (Deploy) على الـ Fork المحلي

    قم بنشر عقد FlashArbEngine مع تحديد عناوين البنية التحتية الحقيقية:

    • Balancer Vault v2: 0xBA12222222228d8Ba445958a75a0704d566BF2C8
    • Uniswap v3 SwapRouter02: 0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45
    • Sushiswap V2 Router: 0xd9e1cE17f2641f24aE83637ab66a2cca9C378804
  • الخطوة 3: محاكاة فروق الأسعار (Price Spread)

    باستخدام سكريبت محاكاة، قم بتنفيذ عملية بيع ضخمة لـ WETH في مجمع Sushiswap لإنشاء خلل سعري وتفاوت في السيولة يدوياً.

  • الخطوة 4: استدعاء executeArbitrage

    قم بتشغيل دالة التنفيذ في العقد الخاص بك مع تمرير المعلمات الدقيقة للقرض الفوري (Flash Loan). تأكد من نجاح المعاملة بسلاسة، وعودة مبلغ القرض إلى Balancer، وبقاء صافي الربح في رصيد FlashArbEngine.

تتطلب ممارسات arbitrage عبر Flash Loan دمج مهارات تطوير برمجيات منخفضة المستوى بلغة Solidity، وفهماً عميقاً لبروتوكولات DeFi، وإعداد بنية تحتية خارج السلسلة (Off-chain) للتعامل مع MEV. العقد الذكي المصمم بعناية مع قروض Balancer v2 الصفرية الرسوم، مدعوماً بقنوات إرسال المعاملات الخاصة (Private Relays)، يمنحك الأساس المتين لبناء نظام تداول آلي بالكامل.

تلخيص هذه التدوينة باستخدام:

FAQ

تستغل مراجحة القروض السريعة المعاملات الذرية غير المضمونة ضمن كتلة EVM واحدة، حيث يتم اقتراض السيولة، وتداولها عبر تجمعات التبادل اللامركزي (DEX) ذات الأسعار المختلفة، وإرجاعها مع الرسوم. إذا لم يغطِ الرصيد النهائي قيمة القرض، تقوم شبكة EVM بإلغاء العملية تلقائياً (Revert)، ولا يتحمل المنقب سوى تكلفة الغاز.

تتم حماية معاملات المراجحة ضد الهجمات من خلال تجاوز ميمبول العامة تماماً. يتم إرسال الحزم الموقعّة من المعاملات مباشرة إلى بناة الكتل عبر قنوات RPC الخاصة مثل Flashbots، مما يضمن التنفيذ الذري دون أي رؤية عامة.

يتم التحقق من الربح عبر فحص الرصيد على مرحلتين داخل دالة رد الاتصال (Callback) لضمان أن صافي الربح يتجاوز الحد الأدنى قبل سداد القرض للخزينة. بينما تُفرض حماية الانزلاق السعري عبر تمرير معاملات مخرجات دنيا وديناميكية إلى وظائف موجه التبادل (AMM Router).
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

...

شاركنا برأيك

لن يتم نشر عنوان بريدك الإلكتروني. الحقول الإلزامية مشار إليها *