LimitOrderBook v2: гайд по интеграции
Этот документ — пошаговая инструкция для frontend (Next.js + Effector) и backend (NestJS keeper) разработчик ов по интеграции с контрактом LimitOrderBook v2.
Перед чтением рекомендуется ознакомиться с LimitOrderBook.md — там описаны контракт, события, ошибки и инварианты.
0. Что есть, что отсутствует
Уже сделано в контрактах:
- Контракт
LimitOrderBookv2 + интерфейсILimitOrderBookвapps/contracts-v2/contracts/LimitOrderBook/. - ABI генерируется в
apps/contracts-v2/typechain-typesи распространяется черезpnpm copy-typechain. FeeRouter(см. FeeRouter.md) — downstream-зависимость.
Что должны сделать фронт/бэк:
- Подцепить ABI и адреса.
- Реализовать UX для размещения, отмены, top-up ордеров и просмотра «My Orders».
- Backend-keeper для исполнения через batch.
- Индексация всех событий ордеров (включая
OrderSkipped,BatchTruncated,RefundDeferred).
Frontend
1. Импорт ABI и адреса
После pnpm build-packages + pnpm copy-typechain фабрики типов появятся в:
packages/typechain-types/factories/contracts/LimitOrderBook/LimitOrderBook__factory.ts
Адреса в конфиге фронта:
// apps/frontend/src/config/contracts.ts
export const CONTRACTS = {
polygon: {
feeRouter: '0x...',
limitOrderBook: '0x...',
uniswapQuoterV2: '0x61fFE014bA17989E743c5F6cB21bF9697530B21e',
// ...
},
};
Инициализация в Next.js (Effector, declarative style):
// apps/frontend/src/entities/limit-order-book/model.ts
import { createStore, createEffect, sample } from 'effector';
import { LimitOrderBook__factory } from '@magnet/typechain-types';
import { ethers } from 'ethers';
import { CONTRACTS } from '@/config/contracts';
export const initBookFx = createEffect((provider: ethers.Provider) =>
LimitOrderBook__factory.connect(CONTRACTS.polygon.limitOrderBook, provider),
);
export const $book = createStore<ReturnType<typeof LimitOrderBook__factory.connect> | null>(null)
.on(initBookFx.doneData, (_, contract) => contract);
2. Чтение базовых параметров
При старте приложения подтягиваем параметры контракта — они нужны UI для валидации формы:
export const loadBookParamsFx = createEffect(
async (book: ethers.Contract): Promise<BookParams> => {
const [minGasDeposit, maxExpiry, maxSlippageBps, maxBatchSize, executorTip] =
await Promise.all([
book.minGasDeposit(),
book.maxExpiry(),
book.maxSlippageBps(),
book.maxBatchSize(),
book.executorTip(),
]);
return { minGasDeposit, maxExpiry, maxSlippageBps, maxBatchSize, executorTip };
},
);
export const $bookParams = createStore<BookParams | null>(null)
.on(loadBookParamsFx.doneData, (_, p) => p);
sample({ clock: $book, filter: Boolean, target: loadBookParamsFx });
В UI:
minGasDeposit→ подставляем как дефолтныйvalueдля placeOrder.maxExpiry→ ограничиваем дропдаун expiry.maxSlippageBps / 100→ верхняя граница ползунка слиппажа в процентах.maxBatchSize→ справочно (для мониторинга глубины очереди).
3. Котировка через Uniswap V3 QuoterV2
Адрес QuoterV2 на Polygon: 0x61fFE014bA17989E743c5F6cB21bF9697530B21e.
import { ethers } from 'ethers';
const QUOTER_V2_ABI = [
'function quoteExactInputSingle((address tokenIn,address tokenOut,uint256 amountIn,uint24 fee,uint160 sqrtPriceLimitX96)) returns (uint256 amountOut,uint160 sqrtPriceX96After,uint32 initializedTicksCrossed,uint256 gasEstimate)',
];
export async function quoteAmountOut(
provider: ethers.Provider,
tokenIn: string,
tokenOut: string,
poolFee: number,
amountIn: bigint,
): Promise<bigint> {
const quoter = new ethers.Contract(
'0x61fFE014bA17989E743c5F6cB21bF9697530B21e',
QUOTER_V2_ABI,
provider,
);
const [amountOut] = await quoter.quoteExactInputSingle.staticCall({
tokenIn,
tokenOut,
amountIn,
fee: poolFee,
sqrtPriceLimitX96: 0,
});
return amountOut;
}
Важно: для лимит-ордера в placeOrder мы передаём targetAmountOut, а не результат quote. Quote нужен фронту только для UX — показать пользователю «текущая рыночная цена ≈ X», чтобы он мог осознанно выставить target.
4. UX flow для размещения ордера
UI поля:
-
Целевая цена — пользователь выбирает либо «получить ≥ N tokenOut» (
targetAmountOut), либо вводит price-per-unit (фронт пересчитывает в total). -
Допустимое проскальзывание — ползунок 1–20% с дефолтом 1%. Маппинг:
const slippageBps = Math.round(slippagePercent * 100);
// 1% → 100 bps; 20% → 2000 bps (= maxSlippageBps) -
Effective minimum — фронт сразу показывает «минимум вы получите Y tokenOut» (это и есть
effectiveMin):const effectiveMin = (targetAmountOut * (10000n - BigInt(slippageBps))) / 10000n; -
Expiry — дропдаун «1 час / 6 часов / 24 часа / 7 дней», ограниченный
maxExpiry. -
Газ-депозит — отдельное информационное поле с
minGasDeposit(0.3 POL по умолчанию), с примечанием «остаток вернётся после исполнения».
5. Approve + placeOrder
import { ethers } from 'ethers';
import { ERC20__factory } from '@magnet/typechain-types';
export const placeOrderFx = createEffect(async (params: PlaceOrderParams) => {
const { signer, book, tokenIn, tokenOut, poolFee, amountIn,
targetAmountOut, slippageBps, expiry, gasDeposit } = params;
// 1. Allowance
const erc20 = ERC20__factory.connect(tokenIn, signer);
const allowance = await erc20.allowance(await signer.getAddress(), book.target);
if (allowance < amountIn) {
const approveTx = await erc20.approve(book.target, amountIn);
await approveTx.wait();
}
// 2. placeOrder
const tx = await book.connect(signer).placeOrder(
tokenIn,
tokenOut,
poolFee,
amountIn,
targetAmountOut,
slippageBps,
expiry,
{ value: gasDeposit },
);
const receipt = await tx.wait();
// 3. Распарсить OrderPlaced
const log = receipt.logs.find((l) => {
try { return book.interface.parseLog(l)?.name === 'OrderPlaced'; }
catch { return false; }
});
const parsed = book.interface.parseLog(log!);
return { orderId: parsed!.args.orderId as bigint, txHash: receipt.hash };
});
Замечание про approve: рекомендуется approve ровно на amountIn, а не на MaxUint256 — это лучше для UX (явный контроль) и важно при работе через мобильные кошельки, где пользователь видит точную сумму.