Разработка системы маршрутизации ордеров через несколько DEX
1inch в момент запуска показал простую идею: если ты ищешь лучшую цену только на Uniswap — ты оставляешь деньги на столе. За несколько лет агрегаторы выросли до сложных систем с split routing, multi-hop маршрутами и специализированными алгоритмами оптимизации. Но смысл не изменился: задача роутера — найти путь от token A к token B с минимальными потерями при заданном объёме.
Разработка собственной системы маршрутизации нужна когда: стандартные агрегаторы (1inch, Paraswap, 0x) не поддерживают нужный чейн; требуется интеграция кастомных протоколов; нужен контроль над источниками ликвидности; или существующие API слишком медленны для торгового бота.
Граф ликвидности как основа маршрутизации
Маршрутизация — это задача поиска пути в взвешенном направленном графе. Вершины — токены. Рёбра — пулы (каждый пул создаёт два направленных ребра: A→B и B→A с ценой в данном направлении).
Для поиска лучшего пути при фиксированном amountIn — задача поиска пути с максимальным произведением обменных курсов (или эквивалентно — минимальной суммой отрицательных логарифмов). Это модификация алгоритма Беллмана-Форда или Дейкстры.
Но есть нюанс, который делает задачу сложнее: цена в пуле зависит от объёма. Для amountIn = 100 USDC лучший маршрут может быть Uniswap V3 пул 0.05%. Для amountIn = 1,000,000 USDC тот же пул даст 3% slippage, а split между несколькими пулами даст 0.3%.
Это превращает задачу из поиска пути в граф с фиксированными весами в оптимизационную задачу с объёмо-зависимыми весами.
Алгоритм split routing
Для крупных ордеров оптимальное решение — не единый маршрут, а распределение объёма по нескольким путям.
Подход через бинарный поиск оптимального split для двух маршрутов:
function findOptimalSplit(
routeA: Route,
routeB: Route,
totalAmount: bigint,
steps: number = 20
): { splitA: bigint; splitB: bigint; totalOut: bigint } {
let bestSplit = { splitA: 0n, splitB: totalAmount, totalOut: 0n }
for (let i = 0; i <= steps; i++) {
const fraction = i / steps
const amountA = BigInt(Math.floor(Number(totalAmount) * fraction))
const amountB = totalAmount - amountA
const outA = amountA > 0n ? simulateRoute(routeA, amountA) : 0n
const outB = amountB > 0n ? simulateRoute(routeB, amountB) : 0n
const totalOut = outA + outB
if (totalOut > bestSplit.totalOut) {
bestSplit = { splitA: amountA, splitB: amountB, totalOut }
}
}
return bestSplit
}
Для N маршрутов задача становится N-мерной оптимизацией — применяют gradient descent или Nelder-Mead с ограничениями (сумма долей = 1, все доли ≥ 0).
Симуляция пулов: точность vs скорость
Uniswap V2: точная формула
function getAmountOutV2(amountIn: bigint, reserveIn: bigint, reserveOut: bigint): bigint {
const amountInWithFee = amountIn * 997n
const numerator = amountInWithFee * reserveOut
const denominator = reserveIn * 1000n + amountInWithFee
return numerator / denominator
}
Uniswap V3: tick traversal
V3 требует итерации по tick bitmap для нахождения ближайших активных tick-ов. Полная симуляция точна, но медленна — несколько миллисекунд на крупный своп с traversal через множество tick-ов.
Для быстрой оценки (при скриннинге маршрутов) используем приближение через текущий sqrtPriceX96 и liquidity без tick traversal — точно для малых объёмов, с погрешностью для крупных. Точную симуляцию запускаем только для финальных кандидатов.
Curve StableSwap: итерационная формула
Curve использует инвариант A * n^n * sum(x_i) + D = A * D * n^n + D^(n+1) / (n^n * prod(x_i)). Расчёт amountOut — итерационный (Newton's method). Для JavaScript/TypeScript — BigInt арифметика с 18-decimal precision.
Balancer WeightedPool
Balancer с весовыми пулами (например, 80/20 BAL/ETH) использует другой инвариант. getAmountOut зависит от весов токенов в пуле — более сложная формула, чем V2.
On-chain vs off-chain маршрутизация
Маршрутизация может происходить полностью on-chain (смарт-контракт находит маршрут прямо в транзакции) или off-chain (вычисления вне чейна, результат передаётся в контракт).
On-chain маршрутизация: полная прозрачность, невозможность манипуляции со стороны aggregator-а. Проблема: ограниченный gas, нельзя перебрать все маршруты. Применяется для простых случаев (2–3 пула maximum).
Off-chain маршрутизация (подход 1inch, Paraswap): вычисления в backend, контракту передаётся готовый маршрут. Контракт только исполняет. Gas эффективнее, маршрут сложнее. Риск: backend может вернуть субоптимальный маршрут. Защита через slippage protection: minAmountOut в транзакции гарантирует пользователю минимум.
Router контракт: исполнение сложных маршрутов
Контракт должен поддерживать гетерогенные маршруты: часть через Uniswap V2, часть через V3, часть через Curve.
struct SwapStep {
address pool;
address tokenIn;
address tokenOut;
uint24 fee; // Для V3
uint8 dexType; // 0=V2, 1=V3, 2=Curve, 3=Balancer
bytes extraData; // Дополнительные параметры под тип DEX
}
function multiSwap(
SwapStep[] calldata steps,
uint256 amountIn,
uint256 minAmountOut,
address recipient
) external returns (uint256 amountOut) {
IERC20(steps[0].tokenIn).transferFrom(msg.sender, address(this), amountIn);
uint256 currentAmount = amountIn;
for (uint256 i = 0; i < steps.length; i++) {
currentAmount = _executeStep(steps[i], currentAmount);
}
require(currentAmount >= minAmountOut, "Slippage exceeded");
IERC20(steps[steps.length-1].tokenOut).transfer(recipient, currentAmount);
return currentAmount;
}
_executeStep диспатчит к конкретной DEX-реализации по dexType. Каждая реализация — отдельная библиотека (Solidity library pattern) для экономии bytecode size.
Кэш состояния пулов
Для быстрой маршрутизации без RPC-вызовов на каждый запрос нужен кэш актуального состояния пулов:
WebSocket subscriptions на события Sync (V2 пулы) и Swap (V3 пулы) через eth_subscribe("logs"). При каждом событии обновляем reserves/sqrtPrice в памяти.
Для 500–1000 активных пулов это ~50–100 событий/блок на Ethereum mainnet. Обработка через event-driven архитектуру (Node.js EventEmitter или Rust tokio channel) с ≤1ms задержкой обновления.
Cold start: при запуске сервиса нужно загрузить текущее состояние всех пулов через multicall. Для 1000 пулов — 5–10 multicall транзакций (до 200 calls каждый), занимает 1–3 секунды.
Сравнение архитектурных подходов
| Подход | Когда подходит | Сложность | Latency |
|---|---|---|---|
| Простой multi-hop | 3–5 чейнов, топ-5 DEX | Низкая | 200–500ms |
| Split routing | Крупные ордера ($50K+) | Средняя | 500ms–1s |
| С кэшем пулов | Торговый бот, < 50ms | Высокая | 10–50ms |
| On-chain router | Максимальная прозрачность | Средняя | 1 блок |
Процесс работы
Аналитика (1–2 дня). Список целевых DEX и чейнов, требования к latency, ожидаемый объём ордеров.
Разработка routing engine (5–7 дней). Граф пулов, алгоритм поиска пути, симуляция пулов, split routing.
Router контракт (3–5 дней). Multi-step execution, Solidity, Foundry fork-тесты.
Кэш и инфраструктура (3–5 дней при необходимости). WebSocket подписки, in-memory кэш пулов.
Ориентиры по срокам
Базовый off-chain роутер через 3–5 DEX с простым multi-hop — 1 неделя. Полноценная система с split routing, кэшем 500+ пулов, кастомным router контрактом и поддержкой V2/V3/Curve/Balancer — 2–3 недели.







