Uniswap v3 ve Sushiswap havuzlarında aynı işlem çiftinin fiyatları arasında fark oluşması oldukça yaygın bir durumdur. Bu genellikle, yüksek volatilite anlarında büyük bir emrin tek bir havuzda fiyatı baskılaması ve ikinci havuzun henüz buna ayak uyduramaması nedeniyle gerçekleşir. Flash Loan (flaş kredi) mekanizması, işlemi gerçekleştirmek için kendi sermayenize ihtiyaç duymadan aradaki bu fiyat farkından kâr elde etmenizi sağlar.
Flaş kredi, EVM üzerinde gerçekleşen atomik bir işlemdir: Bir borç verme protokolünden (Aave, Balancer, Uniswap) $1,000,000 borç alırsınız, DEX A üzerinde varlığı ucuz fiyattan satın alır, DEX B üzerinde daha pahalıya satarsınız, kredi anaparasını komisyonuyla birlikte geri öder ve net kârı cebinize koyarsınız. Çağrı zincirindeki tek bir adım bile başarısız olursa veya nihai bakiye geri ödeme tutarının altında kalırsa, EVM tüm işlemi geri alır (revert). Bu durumda yalnızca gas ücretini kaybetmiş olursunuz.
1. Piyasa Dinamikleri ve “Risksiz” Arbitrajın Gizli Riskleri
“Risksiz” terimi yalnızca pozisyon taşıma riski (directional risk) ve ana sermayeyi kaybetme riskinin bulunmaması ile sınırlıdır. Ancak gerçekte trader çok daha kritik faktörlerle karşı karşıya kalır.

En Büyük Tuzaklar
- Slippage (Fiyat Kayması): Flaş kredi hacmi ne kadar büyük olursa, kendi işleminiz havuz içindeki fiyatı o kadar fazla kaydırır (price impact). Kredi miktarı optimal seviyeyi aşarsa, oluşan spread tamamen kayma nedeniyle eriyip gider.
- MEV ve Front-running: Genel mempool üzerindeki işlemleriniz arama botları (searcher) tarafından anında tespit edilir. Bu botlar, daha yüksek gas ücreti (Priority Fee) teklif ederek kendi swap işlemlerini öne geçirebilir ve fırsatı elinizden kaçırmanıza neden olabilir.
- Gas Maliyeti (Gas Overhead): Birden fazla harici swap içeren karmaşık bir smart contract çağrısı 250,000 ile 450,000 birim gas tüketir. Ethereum L1 üzerinde gas fiyatı 30-50 Gwei seviyelerindeyken, işlem masrafları mikro spread kârlarını kolayca eritebilir.
2. İşlem Ekonomisi ve Kredi Hacmi Hesaplama Matematiği
Arbitrajı başarıyla gerçekleştirmek için, net kârın maksimuma ulaştığı optimal borç alma hacmi x değerinin doğru şekilde hesaplanması gerekir.
Constant Product havuzlarında (x · y = k, örneğin Uniswap v2) fiyat Rx ve Ry rezervlerine bağlıdır. Δx tutarında bir swap gerçekleştirildiğinde alıcının elde edeceği miktar:
Δy = (Ry · Δx · (1 - γ)) / (Rx + Δx · (1 - γ))
buradaki γ havuz komisyonunu temsil eder (örneğin %0.3 için 0.003).
Finansal Kâr Modeli
Profit(x) = SwapOutDEX2(SwapOutDEX1(x)) - x - FlashLoanFee(x) - GasCost
Lider sağlayıcıların flaş kredi komisyon oranları:
- Balancer v2: %0
- Uniswap v3 (Flash Swaps): Havuz katmanına bağlıdır (%0.05, %0.3, %1.0)
- Aave v3: %0.05
3. Likidite Sağlayıcılarının Karşılaştırılması
| Sağlayıcı | Kredi Komisyonu | Gas Maliyeti | Öne Çıkan Özellikler |
|---|---|---|---|
| Balancer v2 | 0.00% | Düşük | Vault üzerinden tek giriş noktası gerektirir, başlangıç için idealdir |
| Aave v3 | 0.05% | Orta | Yüksek likidite, onlarca ağ desteği (Polygon, Arbitrum, Optimism) |
| Uniswap v3 | 0.05% – 0.30% | Yüksek | Harici bir borç verme protokolüne ihtiyaç duymadan doğrudan havuz üzerinden Flash Swap yapılmasına olanak tanır |
4. Adım Adım Arbitraj Uygulama Algoritması
- Spread Değerlendirmesi: Bir node bağlantısı üzerinden WebSocket veya gRPC akışları kullanılarak DEX A (düşük fiyat) ile DEX B (yüksek fiyat) arasındaki fiyat farkının izlenmesi.
- Kâr Hesaplama: Havuz komisyonları, Balancer/Aave kesintileri ve anlık Base Fee + Priority Fee dikkate alınarak optimal
xhacminin hesaplanması. - İşlemin Başlatılması: Sözleşmenin Balancer Vault üzerinden Flash Loan talep etmesi.
- Fonların Alınması: Vault'un talep edilen token'ları arbitraj sözleşmesine göndermesi ve
receiveFlashLoancallback fonksiyonunu tetiklemesi. - Swap 1: Sözleşmenin alınan token'ları DEX A (Uniswap v3) üzerine göndererek karşılığında hedef varlığı elde etmesi.
- Swap 2: Sözleşmenin elde edilen hedef varlığı DEX B (Sushiswap/Uniswap v2) üzerine gönderip tekrar ilk token'a çevirmesi.
- Kredinin Geri Ödenmesi: Sözleşmenin anapara tutarını Balancer Vault'a aktarmak için onay vermesi (approve) veya doğrudan transfer etmesi.
- Kâr Kontrolü: Sözleşmenin son bakiyenin ilk bakiyeden yüksek olduğunu doğrulaması. Aksi takdirde revert fırlatması.
5. Kullanıma Hazır Solidity Smart Contract (OpenZeppelin + Balancer v2)
Bu sözleşme EVM ağları için geliştirilmiştir. Sıfır komisyonlu flaş kredi sağlayıcısı olarak Balancer v2 mekanizmasını kullanır.
// 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. swap koruması için off-chain hesaplanır
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'dan gelen dizi yapılarının doğruluğunun kontrolü
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];
// Swap işlemleri ÖNCESİNDEKİ ilk bakiyeyi sabitleme (gelen kredi + eski bakiyeyi kapsar)
uint256 balanceBefore = IERC20(params.tokenA).balanceOf(address(this));
// 1. Uniswap v3 üzerinde TokenA -> TokenB swap'ı (dinamik slippage koruması)
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. SushiSwap üzerinde TokenB -> TokenA swap'ı (minimum çıktı = repayAmount + minProfit olarak belirlenir)
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. Kredi geri ödemesinden ÖNCE yeterli fonun varlığını kontrol etme
uint256 balanceBeforeRepayment = IERC20(params.tokenA).balanceOf(address(this));
if (balanceBeforeRepayment < repayAmount + params.minProfit) {
revert InsufficientProfit();
}
// 4. Kredinin Balancer Vault'a geri ödenmesi
IERC20(params.tokenA).safeTransfer(
address(balancerVault),
repayAmount
);
// 5. Eski bakiye dikkate alınarak net kârın korunduğunun doğrulanması
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);
}
}Standart ERC-20 token'ları için bu koddaki swap ve kredi geri ödeme sırası mantıksal olarak tam uyumludur. Ağ üzerinde kullanıma almadan önce router adreslerinin uyumluluğu, güncel Balancer komisyon oranları ve spesifik işlemler için minAmountOutV3 değerleri mutlaka kontrol edilmelidir.
6. İşlemlerin Önüne Geçilmesini Önleme (MEV Koruması)
Arbitraj işlemini standart genel RPC'ler (Infura, Alchemy) üzerinden genel mempool'a göndermek, kârı doğrudan sandwich bot'larına kaptırmanın en kesin yoludur. Searcher'lar gelen işlemleri yakından izler ve Front-running / Back-running yöntemlerini kullanırlar.
Pratik Çözüm: Private RPC (Flashbots Builder)
- İşlemlerinizi özel endpoint'ler (
https://rpc.flashbots.net) üzerinden doğrudan blok oluşturucularına (Flashbots, Beaverbuild, Titan) gönderin. - Node.js / Python geliştiricileri için bundle gönderimi (
eth_sendBundle) kullanın; böylece madenciye vereceğiniz madenci bahşişi (Priority Fee) yalnızca işlem başarıyla gerçekleştiğinde ödenir.
7. Flashbots Üzerinden MEV Altyapısıyla Etkileşim (Node.js / Viem)
2026 yılında arbitraj işlemlerini standart eth_sendTransaction kullanarak halka açık şekilde göndermek, MEV botları yüzünden paranızı doğrudan çöpe atmak demektir. İşleminizi korumanın tek yolu, atomik bir paket (Bundle) oluşturup bunu doğrudan blok oluşturuculara (Flashbots, Titan, Beaverbuild, BuilderNet) iletmektir.
DEX üzerindeki koşullar değişir ve işlem bloğa dahil edilmeden önce spread kapanırsa, simülasyon betiği paketi anında reddeder ve builder bunu bloğa dahil etmez. Böylece gas için tek bir Wei bile harcamamış olursunuz.
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] Zorunlu çevre değişkenleri tanımlanmamış');
}
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
);
// Tek bir Node.js süreci içinde paketlerin sırayla işlenmesi.
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] Geçersiz arbitraj sözleşme adresi');
}
if (
typeof calldata !== 'string' ||
!utils.isHexString(calldata)
) {
throw new Error('[Flashbots] Geçersiz calldata');
}
if (
!Number.isSafeInteger(targetBlockNumber) ||
targetBlockNumber < 1
) {
throw new Error('[Flashbots] Geçersiz hedef blok numarası');
}
const network = await provider.getNetwork();
if (network.chainId !== CHAIN_ID) {
throw new Error(
`[Flashbots] Hatalı ağ: ${network.chainId}; Mainnet (${CHAIN_ID}) bekleniyordu`
);
}
const flashbotsProvider = await FlashbotsBundleProvider.create(
provider,
authSigner,
RELAY_URL,
CHAIN_ID
);
const latestBlock = await provider.getBlockNumber();
if (targetBlockNumber <= latestBlock) {
console.warn(
`[Flashbots] Hedef blok ${targetBlockNumber} zaten kaçırıldı`
);
return;
}
// Genel RPC ile nonce senkronizasyonu.
// Kuyruk yalnızca bu Node.js örneğini korur.
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
};
// Başlangıç gas limiti ile ilk simülasyon.
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] Başarılı işlem sonucu bulunamadı'
);
return;
}
const txGasUsed = utils.BigNumber.from(firstResult.gasUsed);
if (txGasUsed.lte(0)) {
console.error('[Simulation Error] Geçersiz gas kullanımı');
return;
}
// Gas limitini %20 artırma.
const adjustedGasLimit = txGasUsed
.mul(GAS_BUFFER_PERCENT)
.div(100);
if (adjustedGasLimit.gt(INITIAL_GAS_LIMIT)) {
console.error(
`[Gas Limit] Hesaplanan limit ${adjustedGasLimit.toString()}, başlangıç limiti olan ${INITIAL_GAS_LIMIT} değerini aşıyor`
);
return;
}
transaction.gasLimit = adjustedGasLimit;
// gasLimit değiştirildikten sonra yeniden imzalama.
signedBundle = await flashbotsProvider.signBundle([
{
signer: executorWallet,
transaction
}
]);
// İmzalalanan işlemin tekrar simüle edilmesi.
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] İşlem doğrulamadan geçemedi'
);
return;
}
// Bloğun henüz oluşturulmadığından emin olunması.
const blockBeforeSubmission = await provider.getBlockNumber();
if (targetBlockNumber <= blockBeforeSubmission) {
console.warn(
`[Flashbots] Blok ${targetBlockNumber} simülasyondan sonra zaten kaçırıldı`
);
return;
}
// Paketin yalnızca belirtilen bloğa gönderilmesi.
const submission = await flashbotsProvider.sendRawBundle(
signedBundle,
targetBlockNumber
);
if ('error' in submission) {
console.error(
`[Submission Error] ${submission.error.message}`
);
return;
}
console.log(
`[Flashbots] Paket ${targetBlockNumber} bloğu için gönderildi; nonce=${nonce}; gasLimit=${adjustedGasLimit.toString()}`
);
const resolution = await submission.wait();
switch (resolution) {
case FlashbotsBundleResolution.BundleIncluded:
console.log(
`[Success] Paket ${targetBlockNumber} bloğuna dahil edildi`
);
break;
case FlashbotsBundleResolution.BlockPassedWithoutInclusion:
console.log(
`[Missed] Blok ${targetBlockNumber} paket dahil edilmeden oluşturuldu`
);
break;
case FlashbotsBundleResolution.AccountNonceTooHigh:
console.error(
'[Nonce Error] Yürütücünün nonce değeri beklenenin ilerisinde'
);
break;
default:
console.warn(
`[Flashbots] Çalıştırma sonucu: ${resolution}`
);
}
}8. Off-Chain İzleme Mimarisi ve Spread Arama
Veri güncellemelerini polling yöntemiyle (setInterval veya REST API) beklemek, 500–1500 ms gibi devasa bir gecikmeye yol açar ve başarılı bir arbitraj şansınızı tamamen bitirir. Prodüksiyon ortamındaki sistemlerde Node.js, Go veya Rust ile yazılmış reaktif mimariler tercih edilir.

Botun Ana Bileşenleri
- 1. Tekilleştirilmiş In-Memory State: Anlık rezervlerin (Uniswap v2 için) ve vektörel tick verilerinin (Uniswap v3 için) doğrudan Node.js/Rust prosesinin belleğinde tutulması.
- 2. Havuz Loglarına Abonelik:
Sync(v2) veSwap(v3) event'lerine WebSocket üzerinden canlı bağlantı. - 3. Anlık Yeniden Hesaplama: Yeni bir log geldiğinde, tüm kontratları polling ile taramak yerine sadece değişikliğin gerçekleştiği parite anında yeniden hesaplanır.
// 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();
});
Çalıştırma:
npm install viemexport ETH_WS_URL='wss://eth-mainnet.g.alchemy.com/v2/API_KEYINIZ'
export POOLS_JSON='[]'
node arbitrage-monitor.mjsPOOLS_JSON='[]' olarak bırakılırsa monitör yalnızca başlatıldıktan sonra oluşturulan havuzları dinlemeye başlar. Mevcut havuzları izlemek için adres ve versiyon bilgilerini POOLS_JSON üzerinden iletebilirsiniz.
9. Gas Optimizasyonu Kontrol Listesi (Gas Optimization)
Karmaşık akıllı kontrat mantıklarını çalıştırırken en az gas harcayan işlem her zaman öne geçer. Tasarruf edilen her 10.000 gas, açık artırma yarışında (PGA) daha yüksek bir Priority Fee vermenize ve bloğa girmeyi garantilemenize olanak tanır.
- Require Yerine Custom Error Kullanın: Klasik
require(condition, "error string")yapıları hata metnini tutmak için minimum 100–200 ekstra gas yakar. Bunun yerineif (!condition) revert InsufficientProfit()yapısına geçmek çok daha ucuzdur. - immutable ve constant Değişken Kullanımı: Router adresleri, Balancer Vault ve temel token'lar
immutableolarak tanımlanmalıdır. Bu işlem değerleri doğrudan kontratın bytecode'una gömer ve pahalı storage okuma (SLOAD— 2100 gas) maliyetinden kurtarır. - transferFrom Yerine Doğrudan Transfer: Kontratınız ara token'larla çalışıyorsa, bunları
transferile doğrudan bir sonraki havuzun adresine gönderin; gereksizapprove/transferFromçağrılarından kaçının. Geçici Depolama (Transient Storage - TSTORE / TLOAD EIP-1153):
EIP-1153 destekleyen ağlarda reentrancy guard bayrakları için geçici depolama kullanın. Bu, durum yazma maliyetini 20.000 gas'ten sadece 100 gas seviyesine düşürür.
10. Adım Adım Dağıtım ve Test Rehberi
Adım 1. Mainnet Ağını Fork'lama (Foundry / Hardhat)
Kontratı canlı ağa deploy etmeden önce, mevcut tüm havuz durumlarını koruyarak yerel bir Mainnet fork'u üzerinde simülasyon yapılması şarttır.
# Anvil (Foundry) üzerinden yerel fork node'u başlatma anvil --fork-url https://eth-mainnet.g.alchemy.com/v2/YOUR_API_KEY --fork-block-number 19800000Adım 2. Kontratın Yerel Fork'a Deploy Edilmesi
Gerçek altyapı adreslerini belirterek
FlashArbEnginekontratını deploy edin:- Balancer Vault v2:
0xBA12222222228d8Ba445958a75a0704d566BF2C8 - Uniswap v3 SwapRouter02:
0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45 - Sushiswap V2 Router:
0xd9e1cE17f2641f24aE83637ab66a2cca9C378804
- Balancer Vault v2:
Adım 3. Fiyat Farkı (Spread) Simülasyonu
Simülasyon script'i ile Sushiswap havuzunda büyük miktarda WETH satışı gerçekleştirerek manuel olarak fiyat dengesizliği ve likidite kayması oluşturun.
Adım 4. executeArbitrage Fonksiyonunun Çağrılması
Hedef kredi parametrelerini tam olarak ileterek yürütücü kontratın fonksiyonunu tetikleyin. İşlemin başarıyla geçtiğinden, kredi anaparasının Balancer'a iade edildiğinden ve net kârın
FlashArbEnginebakiyesinde kaldığından emin olun.
Pratik Flash Loan arbitrajı; Solidity ile düşük seviyeli geliştirme, DeFi protokol mekaniklerini anlama ve MEV odaklı off-chain altyapı kurulumu gibi yetkinliklerin birleşmesini gerektirir. Balancer v2 üzerinden sıfır komisyonlu kredi sağlayan kaliteli bir akıllı kontrat ve özel işlem iletim kanalları (private relays), tamamen otomatik bir alım-satım sistemi kurmak için sağlam bir temel sunar.