Перейти к основному содержимому

LimitOrderBook v2: гайд по интеграции

Этот документ — пошаговая инструкция для frontend (Next.js + Effector) и backend (NestJS keeper) разработчиков по интеграции с контрактом LimitOrderBook v2.

Перед чтением рекомендуется ознакомиться с LimitOrderBook.md — там описаны контракт, события, ошибки и инварианты.

0. Что есть, что отсутствует

Уже сделано в контрактах:

  • Контракт LimitOrderBook v2 + интерфейс 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 поля:

  1. Целевая цена — пользователь выбирает либо «получить ≥ N tokenOut» (targetAmountOut), либо вводит price-per-unit (фронт пересчитывает в total).

  2. Допустимое проскальзывание — ползунок 1–20% с дефолтом 1%. Маппинг:

    const slippageBps = Math.round(slippagePercent * 100);
    // 1% → 100 bps; 20% → 2000 bps (= maxSlippageBps)
  3. Effective minimum — фронт сразу показывает «минимум вы получите Y tokenOut» (это и есть effectiveMin):

    const effectiveMin = (targetAmountOut * (10000n - BigInt(slippageBps))) / 10000n;
  4. Expiry — дропдаун «1 час / 6 часов / 24 часа / 7 дней», ограниченный maxExpiry.

  5. Газ-депозит — отдельное информационное поле с 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 (явный контроль) и важно при работе через мобильные кошельки, где пользователь видит точную сумму.

6. Подписка на события для UI

import { createEvent, sample } from 'effector';

export const orderPlaced = createEvent<OrderPlacedPayload>();
export const orderExecuted = createEvent<OrderExecutedPayload>();
export const orderSkipped = createEvent<OrderSkippedPayload>();
export const orderCancelled = createEvent<bigint>();
export const orderExpired = createEvent<bigint>();
export const refundDeferred = createEvent<{ maker: string; amount: bigint }>();
export const refundClaimed = createEvent<{ maker: string; amount: bigint }>();

export const subscribeBookEventsFx = createEffect(
async ({ book, userAddress }: { book: ethers.Contract; userAddress: string }) => {
book.on(book.filters.OrderPlaced(undefined, userAddress), (orderId, maker, ...rest) => {
orderPlaced({ orderId, maker, ...parseRest(rest) });
});

book.on(book.filters.OrderExecuted(), (orderId, executor, amountOut, gasUsed, payout) => {
orderExecuted({ orderId, executor, amountOut, gasUsed, payout });
});

book.on(book.filters.OrderSkipped(), (orderId, reason) => {
orderSkipped({ orderId, reason });
});

book.on(book.filters.OrderCancelled(), (orderId) => orderCancelled(orderId));
book.on(book.filters.OrderExpired(), (orderId) => orderExpired(orderId));

book.on(book.filters.RefundDeferred(userAddress), (maker, amount) => {
refundDeferred({ maker, amount });
});
book.on(book.filters.RefundClaimed(userAddress), (maker, amount) => {
refundClaimed({ maker, amount });
});
},
);

sample({ clock: $book, filter: Boolean, target: subscribeBookEventsFx });

Что показываем пользователю:

  • OrderPlaced → добавить в стор «Мои ордера», статус Open.
  • OrderExecuted → toast «Ордер #N исполнен, получено X tokenOut», статус → Executed.
  • OrderSkippedопционально скрываем от пользователя. Это нормальный сигнал «цена не дошла», keeper попробует ещё. Можно слушать для дебага.
  • OrderCancelled / OrderExpired → обновить статус карточки.
  • RefundDeferredбольшой жёлтый баннер «У вас есть невыплаченный возврат N POL, нажмите чтобы получить». CTA вызывает claimNativeRefund().
  • RefundClaimed → скрываем баннер.

7. Cancel / Top-up gas

export const cancelOrderFx = createEffect(
async ({ signer, book, orderId }: { signer: ethers.Signer; book: ethers.Contract; orderId: bigint }) => {
const tx = await book.connect(signer).cancelOrder(orderId);
await tx.wait();
},
);

export const topUpGasFx = createEffect(
async ({ signer, book, orderId, amount }: TopUpParams) => {
const tx = await book.connect(signer).topUpGas(orderId, { value: amount });
await tx.wait();
},
);

Замечания:

  • cancelOrder работает даже на паузе контракта. UI не должен дизейблить кнопку «Cancel» по paused().
  • topUpGas работает только пока ордер Open и не истёк (now <= expiry). Если истёк — UI должен предлагать expireOrder либо ничего (это сделает keeper автоматом).

8. Pending refund check

При загрузке страницы профиля / списка ордеров фронт проверяет наличие отложенного refund:

export const checkPendingRefundFx = createEffect(
async ({ book, userAddress }: { book: ethers.Contract; userAddress: string }) => {
const amount: bigint = await book.pendingNativeRefunds(userAddress);
return amount;
},
);

export const claimRefundFx = createEffect(
async ({ signer, book }: { signer: ethers.Signer; book: ethers.Contract }) => {
const tx = await book.connect(signer).claimNativeRefund();
await tx.wait();
},
);

export const $pendingRefund = createStore(0n)
.on(checkPendingRefundFx.doneData, (_, amount) => amount)
.reset(claimRefundFx.done);

UI: если $pendingRefund > 0n — показываем баннер «У вас есть невыплаченный возврат: 0.18 POL» с кнопкой «Забрать».


Backend (keeper)

1. Архитектура сервиса

Keeper реализуется как worker внутри NestJS backend (apps/backend), либо как отдельный микро-сервис. Рекомендуемая структура:

  • OnchainListenerService — слушает события LimitOrderBook через WebSocket провайдера, поддерживает catch-up от последнего обработанного блока.
  • OrderRepository — Postgres, таблицы limit_orders и pending_refunds.
  • BatchExecutorService — раз в N секунд опрашивает Open ордера, фильтрует «созревшие», считает экономику, формирует и шлёт batch.
  • ExpiryCleanupService — отдельный cron, вызывает expireOrder для всех истёкших.
  • Опционально Redis — координация между несколькими keeper-инстансами (leader election / распределённый lock на orderId).

2. Индексация ордеров

Listener

import { ethers } from 'ethers';
import { LimitOrderBook__factory } from '@magnet/typechain-types';

const provider = new ethers.WebSocketProvider(process.env.POLYGON_WS_RPC!);
const book = LimitOrderBook__factory.connect(LOB_ADDRESS, provider);

book.on(book.filters.OrderPlaced(), async (
orderId, maker, tokenIn, tokenOut, poolFee,
amountIn, targetAmountOut, slippageBps, expiry, gasDeposit, event,
) => {
await orderRepo.insert({
order_id: orderId.toString(),
maker,
token_in: tokenIn,
token_out: tokenOut,
pool_fee: Number(poolFee),
amount_in: amountIn.toString(),
target_amount_out: targetAmountOut.toString(),
slippage_bps: Number(slippageBps),
expiry: Number(expiry),
gas_deposit: gasDeposit.toString(),
status: 'open',
created_at: new Date(),
tx_hash: event.log.transactionHash,
});
});

book.on(book.filters.OrderExecuted(), async (orderId, executor, amountOut, gasUsed, payout) => {
await orderRepo.update(orderId.toString(), {
status: 'executed',
executed_at: new Date(),
amount_out: amountOut.toString(),
gas_used: gasUsed.toString(),
executor_payout: payout.toString(),
});
});

book.on(book.filters.OrderCancelled(), async (orderId) => {
await orderRepo.update(orderId.toString(), { status: 'cancelled', cancelled_at: new Date() });
});

book.on(book.filters.OrderExpired(), async (orderId) => {
await orderRepo.update(orderId.toString(), { status: 'expired', expired_at: new Date() });
});

book.on(book.filters.OrderSkipped(), async (orderId, reason) => {
await orderRepo.incrementSkipCounter(orderId.toString(), Number(reason));
});

book.on(book.filters.BatchExecuted(), async (executor, total, executed, skipped) => {
metrics.batchSize.observe(Number(total));
metrics.batchExecuted.inc(Number(executed));
metrics.batchSkipped.inc(Number(skipped));
});

book.on(book.filters.BatchTruncated(), async (executor, total, processed) => {
logger.warn({ executor, total: Number(total), processed: Number(processed) }, 'batch truncated');
metrics.batchTruncated.inc();
});

book.on(book.filters.RefundDeferred(), async (maker, amount) => {
await pendingRefundsRepo.upsert(maker, amount.toString());
});

book.on(book.filters.RefundClaimed(), async (maker, amount) => {
await pendingRefundsRepo.clear(maker);
});

Catch-up при старте

Для надёжности — periodic catch-up от последнего обработанного блока через queryFilter (RPC-лимиты Polygon: чанки по ~5_000 блоков):

async function catchUp(book: ethers.Contract, fromBlock: number, toBlock: number) {
const STEP = 5_000;
for (let from = fromBlock; from <= toBlock; from += STEP) {
const to = Math.min(from + STEP - 1, toBlock);
const logs = await book.queryFilter('*', from, to);
for (const log of logs) {
await handleLog(log); // тот же обработчик, что и в listener
}
await checkpoint.save(to);
}
}

3. Off-chain prefilter (главный цикл)

Каждый блок Polygon (~2-3s) или на push от Swap-event'а Uniswap V3 в нужных пулах:

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)',
];

async function findReadyOrders(): Promise<bigint[]> {
const openOrders = await orderRepo.findOpenWithExpiryAfter(Date.now() / 1000);
const quoter = new ethers.Contract(QUOTER_V2, QUOTER_V2_ABI, provider);
const ready: bigint[] = [];

for (const o of openOrders) {
let out: bigint;
try {
const result = await quoter.quoteExactInputSingle.staticCall({
tokenIn: o.tokenIn,
tokenOut: o.tokenOut,
amountIn: o.amountIn,
fee: o.poolFee,
sqrtPriceLimitX96: 0n,
});
out = result[0];
} catch {
continue; // нет ликвидности / ошибка пула
}

const effectiveMin =
(BigInt(o.targetAmountOut) * (10000n - BigInt(o.slippageBps))) / 10000n;
if (out >= effectiveMin) {
ready.push(BigInt(o.orderId));
}
}
return ready;
}

4. Проверка экономики keeper'а

Перед добавлением ордера в batch проверяем, что исполнение не убыточно:

const feeData = await provider.getFeeData();
const gasPrice = feeData.maxFeePerGas ?? feeData.gasPrice ?? 0n;
const EXPECTED_GAS_PER_ORDER = 220_000n; // эмпирическая оценка одной итерации
const executorTip = BigInt(await book.executorTip());

const profitable = ready.filter((id) => {
const o = orderById.get(id.toString())!;
const expectedCost = EXPECTED_GAS_PER_ORDER * gasPrice + executorTip;
return expectedCost <= BigInt(o.gasDeposit);
// capped payout → executor получит максимум gasDeposit; если cost > deposit, чистый убыток
});

Замечание: значение EXPECTED_GAS_PER_ORDER калибруется по реальным gasUsed из событий OrderExecuted — собирайте метрику gas_used_per_order_p95 и периодически обновляйте константу.

5. Формирование batch'а

const maxBatchSize = Number(await book.maxBatchSize());

// Сортировка: сперва те, что ближе к expiry (рискуют не быть исполненными)
profitable.sort((a, b) => {
const oa = orderById.get(a.toString())!;
const ob = orderById.get(b.toString())!;
return oa.expiry - ob.expiry;
});

let batch = profitable.slice(0, maxBatchSize);

// Защита от OOG: оценить через estimateGas, обрезать хвост при необходимости
const BLOCK_GAS_LIMIT = 30_000_000n;
const GAS_BUDGET = (BLOCK_GAS_LIMIT * 70n) / 100n; // 70% от блока

while (batch.length > 0) {
try {
const estGas = await book.connect(keeperWallet).executeOrders.estimateGas(batch);
if (estGas <= GAS_BUDGET) break;
batch = batch.slice(0, Math.floor(batch.length * 0.8)); // обрезаем 20%
} catch (e) {
// estimateGas может зареверт-ить, если в batch нет ни одного исполнимого
logger.warn({ err: e }, 'estimateGas failed, halving batch');
batch = batch.slice(0, Math.floor(batch.length / 2));
}
}

6. Отправка транзакции

import { NonceManager } from 'ethers';

const keeperWallet = new NonceManager(
new ethers.Wallet(process.env.KEEPER_PRIVATE_KEY!, provider),
);

const tx = await book.connect(keeperWallet).executeOrders(batch, {
maxFeePerGas: gasPrice * 12n / 10n, // +20% запас
maxPriorityFeePerGas: ethers.parseUnits('30', 'gwei'),
});
logger.info({ txHash: tx.hash, batchSize: batch.length }, 'batch sent');

7. Парсинг receipt'а

const receipt = await tx.wait();
let executedCount = 0;
let skippedCount = 0;

for (const log of receipt!.logs) {
let parsed: ethers.LogDescription | null = null;
try { parsed = book.interface.parseLog(log); } catch { continue; }
if (!parsed) continue;

switch (parsed.name) {
case 'OrderExecuted':
// уже обработано в listener'е, но локально считаем для метрик
executedCount++;
break;
case 'OrderSkipped': {
skippedCount++;
const reason = Number(parsed.args.reason);
// SkipReason.SwapFailed (3) — самый частый; ордер останется Open
// SkipReason.Expired (2) — отдельный cron вызовет expireOrder
// SkipReason.NotOpen (1) — кто-то уже cancel-нул или мы передали дубликат
metrics.skipsByReason.inc({ reason });
break;
}
case 'BatchTruncated':
metrics.batchTruncated.inc();
logger.warn({
total: Number(parsed.args.totalRequested),
processed: Number(parsed.args.processed),
}, 'batch truncated — снизить maxBatchSize или повысить gas limit tx');
break;
}
}

8. Истёкшие ордера

Отдельный cron каждые N минут (например, каждые 5):

@Cron('*/5 * * * *')
async cleanupExpired() {
const expired = await orderRepo.findOpen({
expiryBefore: Math.floor(Date.now() / 1000) - 60,
});

for (const o of expired) {
try {
const tx = await book.connect(keeperWallet).expireOrder(o.orderId);
await tx.wait();
logger.info({ orderId: o.orderId }, 'expired');
} catch (e) {
logger.warn({ err: e, orderId: o.orderId }, 'expireOrder failed');
}
}
}

Keeper получает expireTip (по умолчанию 0.005 POL) за каждый вызов — экономически выгодно при газе ≤ tip.

9. Безопасность keeper-кошелька

  • Hot wallet с ограниченным балансом. Например, 50 POL. Refill автоматический через KMS или ручной через multisig.
  • Приватный ключ — только из секрет-стора (AWS Secrets Manager, GCP Secret Manager, Vault), никогда в коде или в git.
  • Изолированный аккаунт — отдельный от рабочих сервисных кошельков. Компрометация keeper'а максимум приведёт к потере его POL-баланса.
  • Rate limit — keeper отправляет максимум одну batch-транзакцию за блок. NonceManager защищает от двойной отправки.
  • Опционально private RPC (bloXroute MEV-blocker / Polygon Merkle RPC) — снижает gas burn при MEV-перехвате.

10. Мониторинг

Prometheus-метрики:

  • keeper_open_orders_total — gauge: сколько Open в БД с expiry > now.
  • keeper_batch_size_avg — histogram: размер batch'а.
  • keeper_skip_rateskipped / processed. Высокий skip-rate (>20%) сигналит о плохом prefilter'е или фрагментированной ликвидности.
  • keeper_executor_net_pol_total — counter: сумма executorPayout - txGasCost (чистая маржа).
  • keeper_batch_truncated_total — counter. Если > 0 за окно — alert, нужно снижать maxBatchSize или повышать gas limit tx.
  • keeper_pending_orders_age_seconds_p99 — gauge: возраст самого старого Open ордера, который уже созрел по цене. Если > N минут — keeper отстаёт, нужен alert.
  • keeper_skips_by_reason{reason=...} — counter по SkipReason.

11. Скелет NestJS-модуля

Согласовано с существующим стилем apps/backend/:

// apps/backend/src/limit-order-keeper/limit-order-keeper.module.ts
import { Module } from '@nestjs/common';
import { ScheduleModule } from '@nestjs/schedule';

import { OnchainListenerService } from './onchain-listener.service';
import { BatchExecutorService } from './batch-executor.service';
import { ExpiryCleanupService } from './expiry-cleanup.service';
import { OrderRepository } from './order.repository';

@Module({
imports: [ScheduleModule.forRoot()],
providers: [
OnchainListenerService,
BatchExecutorService,
ExpiryCleanupService,
OrderRepository,
],
})
export class LimitOrderKeeperModule {}
// apps/backend/src/limit-order-keeper/batch-executor.service.ts
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { ethers } from 'ethers';
import { LimitOrderBook__factory } from '@magnet/typechain-types';

@Injectable()
export class BatchExecutorService implements OnModuleInit {
private readonly logger = new Logger(BatchExecutorService.name);
private book!: ethers.Contract;
private keeperWallet!: ethers.Signer;

async onModuleInit() {
const provider = new ethers.JsonRpcProvider(process.env.POLYGON_RPC);
this.keeperWallet = new ethers.NonceManager(
new ethers.Wallet(process.env.KEEPER_PRIVATE_KEY!, provider),
);
this.book = LimitOrderBook__factory.connect(process.env.LOB_ADDRESS!, provider);
}

@Cron(CronExpression.EVERY_5_SECONDS)
async tick() {
try {
const ready = await this.quoteAndFilter();
if (ready.length === 0) return;
const batch = await this.buildBatch(ready);
if (batch.length === 0) return;
await this.send(batch);
} catch (e) {
this.logger.error('tick failed', e as Error);
}
}

async quoteAndFilter(): Promise<bigint[]> {
// см. раздел 3 + 4
return [];
}

async buildBatch(ready: bigint[]): Promise<bigint[]> {
// см. раздел 5
return ready;
}

async send(batch: bigint[]): Promise<void> {
const tx = await this.book
.connect(this.keeperWallet)
.executeOrders(batch);
const receipt = await tx.wait();
// см. раздел 7 (парсинг receipt'а)
this.logger.log(`batch sent ${tx.hash}, size=${batch.length}`);
}
}

OnchainListenerService строится по тому же шаблону: в onModuleInit подключается к WebSocket-провайдеру, регистрирует все book.on(...) handler'ы (см. раздел 2), и в onModuleDestroy снимает подписки + закрывает WS.

12. Чек-лист интеграции

Фронт:

  • Подцеплен ABI из @magnet/typechain-types.
  • Адрес limitOrderBook v2 в config/contracts.ts.
  • Effector-стор для контракта + загрузка параметров (minGasDeposit, maxSlippageBps, ...).
  • UI слиппажа (% → bps), показ effectiveMin.
  • Approve + placeOrder flow.
  • My Orders с realtime-ценой через quoter.
  • Кнопки Cancel / Top up gas (Cancel не дизейблится на паузе).
  • Подписка на все события + toast'ы по OrderExecuted.
  • Баннер pending refund + claim flow.

Бэк:

  • Listener на все события (OrderPlaced, OrderExecuted, OrderSkipped, OrderCancelled, OrderExpired, GasToppedUp, BatchExecuted, BatchTruncated, RefundDeferred, RefundClaimed).
  • Catch-up от deployment block через queryFilter.
  • Полл-цикл prefilter (quoter staticCall).
  • Экономический фильтр (gasPrice × expectedGas + tip ≤ gasDeposit).
  • executeOrders batch с sort + estimateGas + trim.
  • Парсинг receipt с метриками.
  • Cron expireOrder для истёкших.
  • Hot wallet keeper'а из секрет-стора + NonceManager.
  • Prometheus метрики + alert на BatchTruncated и pending-age.