FeeRouter и LimitOrderBook: гайд по интеграции
Этот документ — пошаговая инструкция для frontend (Next.js + Effector) и backend (NestJS + keeper) разра ботчиков по интеграции с контрактами FeeRouter и LimitOrderBook.
0. Что есть, что отсутствует
Уже сделано в контрактах:
- Деплой контрактов: после деплоя у вас будут адреса
FeeRouterиLimitOrderBook. Их и нужно положить в конфиг приложения. - ABI генерируется в
apps/contracts-v2/typechain-types. Распространяется в остальные пакеты черезpnpm copy-typechain.
Что должны сделать фронт/бэк:
- Подцепить ABI и адреса.
- Реализовать UI-сценарии (свап + лимит-ордера + my-orders).
- Backend-keeper для исполнения ордеров.
- Индексация событий (через The Graph, или собственный listener).
1. ABI и адреса
После pnpm build-packages + pnpm copy-typechain фабрики типов появятся в:
packages/typechain-types/factories/contracts/FeeRouter/FeeRouter__factory.ts
packages/typechain-types/factories/contracts/LimitOrderBook/LimitOrderBook__factory.ts
Адреса контрактов на сетях:
// apps/frontend/src/config/contracts.ts (создать)
export const CONTRACTS = {
polygon: {
feeRouter: '0x...', // подставить после деплоя
limitOrderBook: '0x...',
partnerDiamond: '0x...', // существующий MagnetDiamond
uniswapRouter: '0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45',
uniswapQuoterV2: '0x61fFE014bA17989E743c5F6cB21bF9697530B21e',
wmatic: '0x0d500B1d8E8eF31E21C99d1Db9A6444d3ADf1270',
// экосистемные токены
unit: '0x...',
pulse: '0x...',
boost: '0x...',
xmgt: '0x...',
},
};
2. Frontend: обычный своп
2.1 Структура UI
Минимальная форма:
- Селект
tokenIn/tokenOut(из 4 экосистемных + опционально WMATIC/USDC). - Поле
amountIn. - Расчёт ожидаемого
amountOutчерез Uniswap V3 Quoter с учётом нашей fee. - Кнопка свапа.
2.2 Расчёт ожидаемого output
import { ethers } from 'ethers';
import { IQuoterV2__factory } from '@uniswap/v3-periphery/typechain';
async function quote({
feeRouter,
quoter,
tokenIn,
tokenOut,
poolFee,
amountIn,
}: {
feeRouter: ethers.Contract;
quoter: ethers.Contract;
tokenIn: string;
tokenOut: string;
poolFee: number;
amountIn: bigint;
}): Promise<{ amountOut: bigint; feeAmount: bigint; netInput: bigint }> {
const cfg = await feeRouter.feeConfig();
const totalFeeBps: bigint = cfg.totalFeeBps;
const feeAmount = (amountIn * totalFeeBps) / 10000n;
const netInput = amountIn - feeAmount;
const { amountOut } = await quoter.quoteExactInputSingle.staticCall({
tokenIn,
tokenOut,
amountIn: netInput,
fee: poolFee,
sqrtPriceLimitX96: 0,
});
return { amountOut, feeAmount, netInput };
}
В UI показывайте:
Получите ≈ X TKN_B · комиссия 3% (Y TKN_A)
2.3 Approve + swap
const slippageBps = 100n; // 1%
const minAmountOut = (expectedAmountOut * (10000n - slippageBps)) / 10000n;
// 1. Approve tokenIn → feeRouter
const tokenInContract = new ethers.Contract(tokenIn, ERC20_ABI, signer);
const allowance = await tokenInContract.allowance(userAddress, feeRouter.target);
if (allowance < amountIn) {
await (await tokenInContract.approve(feeRouter.target, ethers.MaxUint256)).wait();
}
// 2. Swap
const tx = await feeRouter.connect(signer).swap(
tokenIn,
tokenOut,
poolFee,
amountIn,
minAmountOut,
userAddress, // recipient
userAddress, // feePayer = сам юзер
);
await tx.wait();
2.4 Effector-стор (фронт)
Рекомендуемая структура (декларативно):
// features/swap/model.ts
import { createEffect, createEvent, createStore, sample } from 'effector';
export const swapRequested = createEvent<SwapParams>();
export const swapFx = createEffect(async (params: SwapParams) => {
const { amountOut } = await quote(params);
const minOut = (amountOut * 9900n) / 10000n;
return feeRouter.swap(...args);
});
export const $isSwapping = createStore(false)
.on(swapFx, () => true)
.reset(swapFx.finally);
sample({ clock: swapRequested, target: swapFx });
3. Frontend: лимит-ордера
3.1 Форма размещения
Поля:
tokenIn,tokenOut,poolFee(выбирается автоматически по самому ликвидному пулу для пары).amountIn.priceTarget(илиminAmountOutнапрямую).expiry(UI: дропдаун «1 час / 6 часов / 24 часа / 7 дней»).
UI должен сразу показывать:
- К оплате:
amountIn TKN_A+0.3 POL(газ-депозит). - Ожидаемое получение по текущему конфигу: через quote (см. 2.2).
- Предупреждение: «До 0.3 POL газ-депозита может быть удержано при исполнении».
3.2 placeOrder
const expiry = Math.floor(Date.now() / 1000) + 24 * 3600;
const gasDeposit = ethers.parseEther('0.3');
await tokenInContract.approve(limitOrderBook.target, ethers.MaxUint256);
const tx = await limitOrderBook.connect(signer).placeOrder(
tokenIn,
tokenOut,
poolFee,
amountIn,
minAmountOut,
expiry,
{ value: gasDeposit },
);
const receipt = await tx.wait();
const event = receipt.logs.find(l => l.fragment?.name === 'OrderPlaced');
const orderId = event.args.orderId;
3.3 My Orders view
Список ордеров пользователя индексируется из событий OrderPlaced (через The Graph или собственный листенер). Для каждого ордера в статусе Open показывайте:
- Текущая рыночная цена vs. target (через quoter каждые N секунд).
- "Дошло X% до исполнения".
- Кнопки Cancel, Top up gas.
- Газ-депозит и сколько примерно нужно для исполнения (через
eth_estimateGasнаexecuteOrder).
3.4 Cancel / TopUp
await limitOrderBook.connect(signer).cancelOrder(orderId);
await limitOrderBook.connect(signer).topUpGas(orderId, { value: ethers.parseEther('0.2') });
4. Backend: keeper
4.1 Архитектура
Сервис, который:
- Слушает событие
OrderPlacedотLimitOrderBookи индексирует в свою БД. - Слушает
OrderCancelled,OrderExecuted,OrderExpired— обновляет статусы. - Каждый блок (или каждые 3–5 секунд) проверяет все
Openордера: можно ли исполнить. - Если можно — шлёт
executeOrderчерез wallet keeper'а.
4.2 Индексация через ethers listeners
import { ethers } from 'ethers';
import { LimitOrderBook__factory } from '@magnet/typechain-types';
const provider = new ethers.JsonRpcProvider(POLYGON_RPC);
const limitOrderBook = LimitOrderBook__factory.connect(LOB_ADDRESS, provider);
limitOrderBook.on(limitOrderBook.filters.OrderPlaced(), async (orderId, maker, tokenIn, tokenOut, poolFee, amountIn, minAmountOut, expiry, gasDeposit, event) => {
await db.orders.upsert({
id: orderId.toString(),
maker,
tokenIn,
tokenOut,
poolFee,
amountIn: amountIn.toString(),
minAmountOut: minAmountOut.toString(),
expiry: Number(expiry),
gasDeposit: gasDeposit.toString(),
status: 'Open',
blockNumber: event.log.blockNumber,
});
});
// Аналогично для OrderCancelled / OrderExecuted / OrderExpired
Для исторических данных при старте — пройти через getLogs от deploymentBlock до latestBlock чанками по 5000 блоков (RPC-лимиты).
4.3 Проверка исполнимости
Каждый блок (или интервал):
const openOrders = await db.orders.findMany({ where: { status: 'Open' } });
for (const order of openOrders) {
if (order.expiry < Math.floor(Date.now() / 1000)) {
// Опционально: вызвать expireOrder для подчистки
continue;
}
const cfg = await feeRouter.feeConfig();
const fee = (BigInt(order.amountIn) * cfg.totalFeeBps) / 10000n;
const netIn = BigInt(order.amountIn) - fee;
let expectedOut: bigint;
try {
expectedOut = (await quoter.quoteExactInputSingle.staticCall({
tokenIn: order.tokenIn,
tokenOut: order.tokenOut,
amountIn: netIn,
fee: order.poolFee,
sqrtPriceLimitX96: 0,
})).amountOut;
} catch {
continue; // нет ликвидности
}
if (expectedOut < BigInt(order.minAmountOut)) continue;
// Оценить газ
const gas = await limitOrderBook.executeOrder.estimateGas(order.id);
const gasPrice = (await provider.getFeeData()).gasPrice ?? 0n;
const estCost = gas * gasPrice + BigInt(EXECUTOR_TIP);
if (estCost + SAFETY_MARGIN >= BigInt(order.gasDeposit)) continue;
// Шлём
try {
const tx = await limitOrderBook.connect(keeperWallet).executeOrder(order.id);
await tx.wait();
} catch (e) {
logger.warn({ err: e, orderId: order.id }, 'execute failed');
}
}
4.4 Защита от MEV
Между моментом «бот видит, что ордер можно исполнить» и моментом «tx майнится» сторонний MEV-бот может перехватить. Для нас это нейтрально:
- Maker получает свою цену (≥ minAmountOut).
- Кто бы ни исполнил — keeper или MEV-бот — комиссия и партнёрка работают одинаково.
- Хорошо: даже если наш keeper упал, ордер исполнят сторонние боты.
Для снижения wasted gas наш бот может слать tx через Polygon private mempool (bloXroute MEV-blocker, Merkle/Flashbots-style RPC). Это снижает кол ичество tx, которые пришли «вторыми» и сожгли газ зря.
4.5 Конфиг keeper'а
// apps/backend/keeper.config.ts
export const KEEPER = {
pollInterval: 5_000, // мс
safetyMargin: parseEther('0.02'), // 0.02 POL запас
maxGasPrice: parseUnits('1000', 'gwei'),
privateRpcUrl: process.env.POLYGON_PRIVATE_RPC, // bloXroute
};
4.6 Мониторинг
Метрики, которые рекомендуется собирать:
keeper_orders_active— Open ордера в БД.keeper_executions_total(counter по success/fail).keeper_skip_reason(counter: price, gas, liquidity).keeper_revenue_pol— сколько POL заработали на tips.
5. Сценарий «зарегистрировать новую пару»
Только owner (multisig):
feeRouter.setPairAllowed(tokenA, tokenB, true);
feeRouter.setPairAllowed(tokenB, tokenA, true); // если двунаправленно
После этого фронт должен подхватить новую пару (через индексатор события PairAllowedUpdated).
6. Сценарий «обновить fee config»
Только owner:
feeRouter.setFeeConfig({
totalFeeBps: 300,
staticRecipients: [
{ wallet: treasury, shareBps: 4000 },
{ wallet: dev, shareBps: 1000 },
{ wallet: marketing, shareBps: 1000 },
],
partnerLineBps: [800, 700, 500, 400, 300, 200, 200, 200, 100, 100, 100, 100, 100, 100, 100],
});
Сумма всех bps должна быть ровно 10000. Контракт revert'ит при невалидном конфиге.
7. Чек-лист интеграции
Фронт:
- Подцепил ABI из typechain.
- Положил адреса в config.
- Реализовал quote (учёт fee).
- Реализовал approve flow.
- Реализовал swap + ошибки.
- Реализовал placeOrder + газ-депозит.
- Реализовал cancel / topUp.
- My orders view с realtime-ценой.
- Обработал события OrderExecuted (показать toast).
Бэк:
- Listener на OrderPlaced / OrderCancelled / OrderExecuted / OrderExpired.
- Полл-цикл проверки исполнимости (каждые 5с).
- Quoter + газ-оценка.
- Keeper wallet с POL для газа.
- Private RPC для отправки tx.
- Метрики + alerting (Sentry / Prometheus).
- Опциональный cleanup expired ордеров (вызов
expireOrder).