Разница в ценах на одну и ту же пару в пулах Uniswap v3 и Sushiswap возникает регулярно. Обычно это происходит в моменты высокой волатильности, когда крупный ордер продавливает цену в одном пуле, а второй еще не успел подтянуться. Flash Loan (мгновенный займ) позволяет забрать эту разницу, не имея собственного капитала для совершения сделки.
Флеш-займ - это атомарная транзакция в EVM: вы берете $1,000,000 в протоколе лендинга (Aave, Balancer, Uniswap), покупаете актив дешевле на DEX A, продаете дороже на DEX B, возвращаете тело займа с комиссией и забираете чистый профит. Если в цепочке вызовов хотя бы один этап дает сбой или финальный баланс оказывается ниже суммы возврата, EVM откатывает (revert) всю транзакцию. Вы теряете только плату за газ.
1. Рыночная механика и скрытые риски “безрискового” арбитража
Термин “безрисковый” относится исключительно к отсутствию риска удержания позиции (directional risk) и риска потери тела депозита. Однако в реальности трейдер сталкивается с другими критическими факторами.

Главные подводные камни
- Slippage (Проскальзывание): Чем больше объем флеш-займа, тем сильнее ваша собственная сделка сдвигает цену внутри пула (price impact). Если размер займа превышает оптимальный, спред полностью съедается проскальзыванием.
- MEV и Front-running: Ваши транзакции в публичном мемпуле видят поисковые боты (searchers). Они могут перехватить сделку, вставив свой свап с более высокой комиссией за газ (Priority Fee).
- Плата за газ (Gas Overhead): Вызов сложного смарт-контракта с несколькими внешними свапами требует от 250,000 до 450,000 units of gas. На 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 v2 | 0.00% | Низкие | Требует единой точки входа через Vault, идеален для старта |
| Aave v3 | 0.05% | Средние | Высокая ликвидность, поддержка десятков сетей (Polygon, Arbitrum, Optimism) |
| Uniswap v3 | 0.05% – 0.30% | Высокие | Позволяет делать Flash Swap напрямую из пула без привлечения внешнего лендинга |
4. Пошаговый алгоритм исполнения арбитража
- Оценка спреда: Мониторинг разницы цен между DEX A (низкая цена) и DEX B (высокая цена) через WebSocket-подключение к ноде или GRPC-потоки.
- Расчет профита: Расчет оптимального размера
xс учетом комиссий пулов, процента Balancer/Aave и текущего Base Fee + Priority Fee. - Запуск транзакции: Контракт запрашивает Flash Loan у Balancer Vault.
- Прием средств: Vault отправляет запрашиваемые токены на контракт арбитража и вызывает коллбэк-функцию
receiveFlashLoan. - Свап 1: Контракт отправляет полученный токен в DEX A (Uniswap v3), взамен получая целевой актив.
- Свап 2: Контракт отправляет полученный целевой актив в DEX B (Sushiswap/Uniswap v2), обменивая его обратно на исходный токен.
- Возврат займа: Контракт одобрил (approve) или напрямую перевел сумму тела займа в Balancer Vault.
- Проверка профита: Контракт проверяет, что финальный баланс больше начального. Если нет — бросает 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) в public mempool — прямой способ отдать прибыль ботам-сэндвичам. Searchers отслеживают входящие сделки и используют методы Front-running / Back-running.
Практическое решение: Private RPC (Flashbots Builder)
- Отправляйте транзакции напрямую билдерам блоков (Flashbots, Beaverbuild, Titan) через приватные эндпоинты (
https://rpc.flashbots.net). - Для разработчиков на Node.js / Python используйте отправку бандлов (
eth_sendBundle), определяя максимальный размер чаевых майнеру (Priority Fee) только в случае успешного выполнения сделки.
7. Взаимодействие с MEV-инфраструктурой через Flashbots (Node.js / Viem)
Публичная отправка арбитражных транзакций через стандартный eth_sendTransaction в 2026 году гарантирует убыток из-за MEV-ботов. Единственный способ защитить сделку — отправка атомарного бандла (Bundle) напрямую билдерам блоков (Flashbots, Titan, Beaverbuild, BuilderNet).
Если условия на DEX изменились и спред схлопнулся до включения транзакции в блок, скрипт симуляции отбросит бандл, а билдер просто не включит его в блок. Вы не потратите ни одного Wei на газ.
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. Архитектура офчейн-мониторинга и поиска спредов
Ожидание обновлений через polling (setInterval или REST API) дает задержку в 500–1500 мс, что полностью исключает шансы на успешный арбитраж. В продакшен-системах используется реактивная архитектура на Node.js, Go или Rust.

Главные компоненты бота
- 1. Единый In-Memory State: Хранение текущих резервов (для Uniswap v2) и векторных тиков (для Uniswap v3) прямо в памяти процесса Node.js/Rust.
- 2. Подписка на логи пулов: Подключение по WebSocket к событиям
Sync(v2) иSwap(v3). - 3. Мгновенный перерасчет: При получении нового лога пересчитывается только та пара, в которой произошли изменения, без полного опроса всех контрактов.
// 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 viemexport ETH_WS_URL='wss://eth-mainnet.g.alchemy.com/v2/ВАШ_API_KEY'
export POOLS_JSON='[]'
node arbitrage-monitor.mjsPOOLS_JSON='[]' означает, что монитор начнет подписываться на пулы, созданные после запуска. Чтобы мониторить уже существующие пулы, передайте их адреса и версии через POOLS_JSON.
9. Чек-лист оптимизации затрат на газ (Gas Optimization)
При выполнении сложной логики смарт-контракта побеждает тот, чья транзакция потребляет меньше units of gas. Каждые 10,000 сэкономленного газа позволяют ставить более высокий Priority Fee и выигрывать гонку аукциона.
- Используйте Custom Errors вместо Require: Конструкции
require(condition, "error string")сжигают минимум 100–200 дополнительного газа на хранение строки ошибки. Переход наif (!condition) revert InsufficientProfit()существенно дешевле. - Применение immutable и constant переменных: Адреса роутеров, Balancer Vault и базовых токенов должны объявляться с флагами
immutable. Это запекает значения прямо в байткод контракта, избавляя от дорогостоящих операций чтения из памяти storage (SLOAD— 2100 gas). - Прямые переводы вместо transferFrom: Если ваш контракт работает с промежуточными токенами, передавайте их напрямую на адрес следующего пула с помощью
transfer, избегая лишних вызововapprove/transferFrom. Transient Storage (TSTORE / TLOAD EIP-1153):
В сетях с поддержкой EIP-1153 используйте транзитное хранилище для флагов защиты от повторного входа (reentrancy guard). Это снижает стоимость записи состояния с 20,000 до 100 gas.
10. Пошаговая инструкция развертывания и тестирования
Шаг 1. Форк сети в Mainnet (Foundry / Hardhat)
Перед деплоем контракта в реальную сеть необходимо провести симуляцию на локальном форке Mainnet с сохранением всех существующих состояний пулов.
# Запуск локального узла-форка через Anvil (Foundry) anvil --fork-url https://eth-mainnet.g.alchemy.com/v2/YOUR_API_KEY --fork-block-number 19800000Шаг 2. Развертывание контракта в локальном форке
Выполните деплой контракта
FlashArbEngineс указанием реальных адресов инфраструктуры:- Balancer Vault v2:
0xBA12222222228d8Ba445958a75a0704d566BF2C8 - Uniswap v3 SwapRouter02:
0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45 - Sushiswap V2 Router:
0xd9e1cE17f2641f24aE83637ab66a2cca9C378804
- Balancer Vault v2:
Шаг 3. Имитация спреда
С помощью скрипта симуляции совершите крупную продажу WETH в пуле Sushiswap, чтобы вручную создать дисбаланс цен и перекос ликвидности.
Шаг 4. Вызов executeArbitrage
Вызовите функцию исполняющего контракта, передав точные параметры целевого займа. Убедитесь, что транзакция проходит успешно, тело займа возвращается в Balancer, а чистая прибыль остаётся на балансе
FlashArbEngine.
Практический Flash Loan арбитраж требует объединения навыков низкоуровневой разработки на Solidity, понимания DeFi-протоколов и настройки офчейн-инфраструктуры для работы с MEV. Качественный смарт-контракт с нулевой комиссией за займ через Balancer v2 и закрытые каналы отправки транзакций дают надежную основу для создания полностью автоматизированной торговой системы.