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

LimitOrderBook (v2)

Обзор

LimitOrderBook v2 — on-chain книга лимит-ордеров с permissionless-исполнением и per-order слиппажем. Maker депонирует tokenIn в контракт + кладёт газ-депозит в POL и указывает целевой targetAmountOut + slippageBps; контракт на лету выводит effectiveMin = targetAmountOut * (10000 - slippageBps) / 10000 и пытается пройти своп через FeeRouter. Любой адрес (наш бэкенд-keeper или сторонний бот) может вызвать executeOrder или batch-вариант executeOrders; за это он получает компенсацию газа + tip из газ-депозита maker'а.

Что нового в v2 по сравнению с v1:

  • targetAmountOut + slippageBps вместо «голого» minAmountOut. Логика проскальзывания живёт в контракте — фронт и keeper больше не дублируют формулу.
  • Batch-исполнение: executeOrders(uint256[]) обрабатывает массив ордеров в одной транзакции. Ордер, который нельзя исполнить (не Open / истёк / своп зареверчен), пропускается (OrderSkipped), а не реверт-ит весь батч.
  • Soft skip в executeOrder: даже для одиночного исполнения контракт не падает на soft-fail условиях — возвращает false и эмитит OrderSkipped. Это упрощает интеграцию keeper'а.
  • Capped payout вместо strict revert. Если из-за скачка газа gasUsed * tx.gasprice + executorTip > gasDeposit, payout капается до gasDeposit. Ордер всё равно становится Executed, разницу проглатывает executor — но amountIn уже потрачен, поэтому реверт был бы хуже.
  • Pull-payment refund. Если maker — контракт с тяжёлым receive(), native-возврат уйдёт в pendingNativeRefunds[maker], эмитится RefundDeferred; maker позже забирает через claimNativeRefund().
  • Защитные admin-функции: revokeAllowance(token, router) для миграции FeeRouter, ограничения setMaxBatchSize (≤ 500), setMinOrderGas, setMaxSlippageBps.

Базовые свойства:

  • Иммютабельный контракт. Solidity 0.8.27.
  • Ownable2Step + Pausable + ReentrancyGuard.
  • Свопы идут через FeeRouter.swap() с feePayer = maker и recipient = maker — партнёры maker'а получают свою долю комиссии, даже если ордер исполнил сторонний keeper.
  • Native transfers через .call{value:} с проверкой return (никаких .transfer с фиксированными 2300 gas).

Контекст и зависимости

  • FeeRouter — обязательный downstream-контракт. Перед placeOrder пара должна быть в feeRouter.pairAllowed(tokenIn, tokenOut). Сам своп делает FeeRouter — удерживает комиссию, раздаёт партнёрам, остаток отправляет в Uniswap V3.
  • Maker — пользователь, разместивший ордер. Получает tokenOut напрямую от Uniswap (минуя баланс LimitOrderBook), плюс остаток газ-депозита.
  • Executor — любой адрес, вызывающий executeOrder / executeOrders / expireOrder. По дизайну предполагается наш бэкенд-keeper, но никаких ограничений нет — даже сторонний MEV-бот желателен как fallback.

Состояние

Публичные переменные (изменяемы owner'ом, default'ы заданы при деплое):

ПолеТипDefaultНазначение
feeRouterIFeeRouter— (constructor)Текущий FeeRouter.
minGasDeposituint2560.3 etherМинимум msg.value для placeOrder.
maxExpiryuint2567 daysМаксимальное «время жизни» ордера от момента размещения.
executorTipuint2560.01 etherПремия исполнителю при успешном executeOrder.
expireTipuint2560.005 etherПремия caller'у expireOrder.
gasOverheaduint25650_000Добавляется к замеру gasleft() чтобы компенсировать финальные операции и события.
maxSlippageBpsuint162_000 (20%)Верхняя граница slippageBps, которую maker может указать.
maxBatchSizeuint1650Максимум ордеров в одном вызове executeOrders. Cap при setter'е — 500.
minOrderGasuint256250_000Если gasleft() в батче падает ниже — цикл прерывается (см. BatchTruncated).
nextOrderIduint2560Счётчик ID. Первый ордер получит orderId = 0.
pendingNativeRefundsmapping(address => uint256)0Накопленные несостоявшиеся native-возвраты maker'у (pull-payment).

Внутренний mapping: _orders[orderId] => Order. Геттер — orders(uint256) returns (Order memory).

Константы:

  • BPS_DENOMINATOR = 10_000 (public).
  • MAKER_REFUND_GAS = 5_000 (private) — лимит газа на native-возврат maker'у; если не хватит, сумма уходит в pendingNativeRefunds.

Структура Order

enum OrderStatus { None, Open, Executed, Cancelled, Expired }

struct Order {
address maker;
address tokenIn;
address tokenOut;
uint24 poolFee; // Uniswap V3 fee tier, зафиксирован на момент placeOrder
uint256 amountIn;
uint256 targetAmountOut; // желаемый output maker'а
uint16 slippageBps; // допуск проскальзывания
uint64 expiry;
uint256 gasDeposit;
OrderStatus status;
}

effectiveMin не хранится: считается на лету при каждой попытке исполнения.

SkipReason

Возвращается в событии OrderSkipped:

enum SkipReason { None, NotOpen, Expired, SwapFailed }
  • None — нормальное успешное исполнение (в OrderSkipped никогда не пишется).
  • NotOpen — на момент попытки ордер уже не Open (Cancelled / Executed / Expired или вообще не существует).
  • Expiredblock.timestamp > order.expiry. Контракт не меняет статус на Expired — это делает только expireOrder. Соответственно, такой ордер навсегда «застрял» в Open, пока keeper не подберёт его через expireOrder.
  • SwapFailedfeeRouter.swap зареверчена. Чаще всего это значит «цена не дошла до effectiveMin», но также может быть «нет ликвидности в пуле», «pair был выключен в FeeRouter» и т.д. Ордер остаётся Open и может быть переисполнен позже.

События

Maker / Executor lifecycle

  • OrderPlaced(orderId, maker, tokenIn, tokenOut, poolFee, amountIn, targetAmountOut, slippageBps, expiry, gasDeposit) — индексер кладёт ордер в БД.
  • OrderCancelled(orderId) — maker отменил, статус → Cancelled.
  • OrderExecuted(orderId, executor, amountOut, gasUsed, executorPayout) — успешное исполнение. executorPayout уже учитывает capped-payout (см. ниже), может быть меньше «честной» оценки.
  • OrderSkipped(orderId, reason) — soft-fail, ордер не сменил статус. Эмитится из executeOrder (одиночный) и из executeOrders (batch).
  • OrderExpired(orderId, by) — статус → Expired, caller получил expireTip.
  • GasToppedUp(orderId, added, newTotal) — кто-то пополнил газ-депозит.

Batch и refund

  • BatchExecuted(executor, totalRequested, executedCount, skippedCount) — итог executeOrders. totalRequested — длина переданного массива.
  • BatchTruncated(executor, totalRequested, processed) — эмитится только если цикл прервался досрочно по gasleft() < minOrderGas (processed < totalRequested). Сигнал мониторингу keeper'а: либо повышать газ-лимит транзакции, либо уменьшать batch.
  • RefundDeferred(maker, amount) — native-возврат не дошёл (receive у maker зареверчен / не хватило MAKER_REFUND_GAS), сумма ушла в pendingNativeRefunds.
  • RefundClaimed(maker, amount) — maker забрал отложенный refund.

Admin

  • FeeRouterUpdated(feeRouter).
  • MinGasDepositUpdated, MaxExpiryUpdated, ExecutorTipUpdated, ExpireTipUpdated, GasOverheadUpdated, MaxSlippageBpsUpdated, MaxBatchSizeUpdated, MinOrderGasUpdated — каждый параметр имеет свой setter и своё событие.
  • AllowanceRevoked(token, router) — admin отозвал ERC20 allowance, ранее выданное контрактом этому router'у.
  • EmergencyWithdrawn(token, amount) / EmergencyWithdrawnNative(amount) — owner забрал застрявший токен / native.

Ошибки

  • InsufficientGasDepositmsg.value < minGasDeposit.
  • InvalidExpiryexpiry <= now или expiry > now + maxExpiry.
  • InvalidAmountamountIn == 0 или targetAmountOut == 0.
  • InvalidSlippageslippageBps == 0 или slippageBps > maxSlippageBps. Также бросается из setMaxSlippageBps при невалидном новом значении.
  • PairNotAllowed — пара не в allowlist FeeRouter.
  • NotOpen — статус ордера не OpencancelOrder / topUpGas / expireOrder).
  • NotMakercancelOrder вызван не maker'ом.
  • NotExpiredexpireOrder вызван до истечения expiry.
  • AlreadyExpiredtopUpGas вызван на ордере, у которого уже block.timestamp > expiry.
  • TransferFailed.call{value:} вернул false (executor payout / claimNativeRefund / emergencyWithdrawNative).
  • ZeroAddressaddress(0) передан туда, где не должен быть (constructor, setFeeRouter).
  • EmptyBatchexecuteOrders вызвана с пустым массивом.
  • BatchTooLarge — длина массива > maxBatchSize, либо setMaxBatchSize с value > 500 или value == 0.
  • NoPendingRefundclaimNativeRefund без накопленной суммы.

Функции maker

placeOrder

function placeOrder(
address tokenIn,
address tokenOut,
uint24 poolFee,
uint256 amountIn,
uint256 targetAmountOut,
uint16 slippageBps,
uint64 expiry
) external payable nonReentrant whenNotPaused returns (uint256 orderId);

Валидация по порядку:

  1. msg.value >= minGasDeposit.
  2. expiry > now && expiry <= now + maxExpiry.
  3. amountIn != 0 && targetAmountOut != 0.
  4. slippageBps != 0 && slippageBps <= maxSlippageBps.
  5. feeRouter.pairAllowed(tokenIn, tokenOut) == true.

После валидации:

  • safeTransferFrom(msg.sender, this, amountIn). Maker должен заранее сделать approve(tokenIn, limitOrderBook, amountIn).
  • Создаётся Order со статусом Open. gasDeposit = msg.value.
  • Эмитится OrderPlaced.

cancelOrder

function cancelOrder(uint256 orderId) external nonReentrant;

Только maker. Работает даже если контракт на паузе (умышленно — пауза не должна замораживать средства). Эффект:

  • Статус → Cancelled.
  • amountIn возвращается maker'у (safeTransfer).
  • Весь gasDeposit возвращается maker'у через _refundMaker (с pull-payment fallback).
  • Эмитится OrderCancelled.

topUpGas

function topUpGas(uint256 orderId) external payable whenNotPaused;

Любой адрес может пополнить газ-депозит конкретного Open-ордера. Проверяется:

  • Ордер Open.
  • block.timestamp <= order.expiry — для уже истёкших ордеров (которые ещё не были expireOrder-нуты) реверт AlreadyExpired.

order.gasDeposit += msg.value, эмитится GasToppedUp.

claimNativeRefund

function claimNativeRefund() external nonReentrant;

Maker забирает накопленный отложенный refund. Реверт NoPendingRefund, если баланс нулевой. CEI: обнуление до .call. Эмитится RefundClaimed.

Функции executor

executeOrder

function executeOrder(uint256 orderId)
external
nonReentrant
whenNotPaused
returns (bool executed);

Soft-fail: не реверт-ит на условиях NotOpen / Expired / SwapFailed. Возвращает (false) и эмитит OrderSkipped(orderId, reason). Это сделано симметрично с batch — чтобы keeper не различал две кодовые ветки.

При успехе:

  • Логика, идентичная одной итерации batch (см. _tryExecute ниже).
  • Возвращает true, эмитит OrderExecuted.

executeOrders

function executeOrders(uint256[] calldata orderIds)
external
nonReentrant
whenNotPaused
returns (uint256 executedCount);

Batch-исполнение. Шаги:

  1. n = orderIds.length. Если n == 0 → реверт EmptyBatch. Если n > maxBatchSize → реверт BatchTooLarge.
  2. Цикл по массиву:
    • Перед каждой итерацией проверяется gasleft() >= minOrderGas. Если нет — break. Это страховка от gas-bomb атаки одного из ордеров (см. ниже).
    • Вызывается внутренний _tryExecute(orderIds[i]).
    • На успех — executedCount++. На любой soft-fail — OrderSkipped + skippedCount++.
  3. Если цикл прервался досрочно (processed < n) — эмитится BatchTruncated(msg.sender, n, processed).
  4. В конце — BatchExecuted(msg.sender, processed, executedCount, skippedCount).

Важно: дубликаты в массиве не реверт-ят весь батч. После первого успешного исполнения второй экземпляр того же orderId уйдёт в skip с reason = NotOpen.

Внутренняя _tryExecute

Реальная логика исполнения. Шаги:

  1. gasStart = gasleft().
  2. Soft-проверки: статус Open, block.timestamp <= expiry. На fail возвращается соответствующий SkipReason.
  3. Локально читаются tokenIn, tokenOut, poolFee, amountIn, maker, deposit, и вычисляется effectiveMin = targetAmountOut * (BPS_DENOMINATOR - slippageBps) / BPS_DENOMINATOR.
  4. _approveFeeRouter(tokenIn, amountIn) — ленивый forceApprove(router, type(uint256).max) при недостатке allowance (с предварительным сбросом в 0 для USDT-like токенов).
  5. try feeRouter.swap(tokenIn, tokenOut, poolFee, amountIn, effectiveMin, maker /* recipient */, maker /* feePayer */):
    • catch(false, SkipReason.SwapFailed). Статус ордера не меняется, ничего не списано (FeeRouter откатывает safeTransferFrom).
  6. После успешного свопа статус сразуExecuted. amountIn уже потрачен; точку невозврата прошли.
  7. gasUsed = gasStart - gasleft() + gasOverhead.
  8. payout = gasUsed * tx.gasprice + executorTip. См. раздел про capped payout — payout капается до deposit.
  9. _payExecutor(msg.sender, payout).
  10. Если deposit > payout_refundMaker(maker, deposit - payout) (с pull-payment fallback).
  11. Эмитится OrderExecuted(orderId, msg.sender, amountOut, gasUsed, payout).

expireOrder

function expireOrder(uint256 orderId) external nonReentrant;

Любой может вызвать, если ордер Open и block.timestamp > expiry. Эффект:

  • Статус → Expired.
  • amountIn возвращается maker'у.
  • tip = min(expireTip, gasDeposit) уходит caller'у (если > 0).
  • Если gasDeposit > tip — остаток уходит maker'у через _refundMaker.
  • Эмитится OrderExpired(orderId, msg.sender).

Capped payout (gas-spike)

В v1 при gasCost + tip > gasDeposit транзакция реверт-илась и ордер оставался Open. Проблема: к моменту реверта feeRouter.swap уже потратил amountIn (а в нашем _tryExecute своп идёт до проверки payout); сворачивать всё назад нельзя.

В v2 поведение пересмотрено:

uint256 gasUsed = gasStart - gasleft() + gasOverhead;
uint256 payout = gasUsed * tx.gasprice + executorTip;
if (payout > deposit) {
payout = deposit; // executor «ест» разницу
}
  • Ордер становится Executed, maker получает tokenOut.
  • Executor получает не больше deposit — разницу проглатывает.
  • Maker не получает остаток газ-депозита (его весь съел executor).
  • OrderExecuted.executorPayout отражает фактическую выплату (т.е. может быть равен deposit).

Keeper'у это выгодно средне-долгосрочно, потому что в обычные дни payout < deposit и часть депозита остаётся как маржа. Тем не менее, перед отправкой batch'а keeper должен проверять expectedCost vs gasDeposit (см. integration-гайд) — гонять убыточные ордера экономически невыгодно.

Pull-payment refund

Native-возврат maker'у делается через _refundMaker:

(bool ok, ) = to.call{ value: amount, gas: MAKER_REFUND_GAS }('');
if (!ok) {
pendingNativeRefunds[to] += amount;
emit RefundDeferred(to, amount);
}
  • Лимит MAKER_REFUND_GAS = 5_000 подобран так, чтобы простой EOA или контракт с пустым receive() прошли, а контракт с тяжёлой логикой в receive() (или вовсе без него) — нет.
  • В случае fail сумма копится в pendingNativeRefunds[maker]. Maker позже вызывает claimNativeRefund() — там лимит газа стандартный (call без gas:-параметра), достаточный для произвольного receive().
  • Это защищает batch от того, чтобы один «злой» maker мог сорвать выплаты executor'у через DoS в receive().

Функции admin (только owner)

Все требуют onlyOwner (Ownable2Step — нужна acceptOwnership после transferOwnership).

Тюнинг параметров:

  • setMinGasDeposit(uint256).
  • setMaxExpiry(uint256).
  • setExecutorTip(uint256).
  • setExpireTip(uint256).
  • setGasOverhead(uint256).
  • setMaxSlippageBps(uint16) — реверт InvalidSlippage, если значение == 0 или > BPS_DENOMINATOR (10000).
  • setMaxBatchSize(uint16) — реверт BatchTooLarge, если == 0 или > 500. Жёсткий cap защищает от случайного OOG при batch'е.
  • setMinOrderGas(uint256) — порог gasleft() для break в executeOrders.

Управление FeeRouter:

  • setFeeRouter(address router) — миграция на новый router. Реверт ZeroAddress при address(0). Не сбрасывает старые allowance — для этого есть отдельная функция.
  • revokeAllowance(address token, address router)forceApprove(router, 0). Используется после setFeeRouter, чтобы старый router больше не мог дёрнуть transferFrom с этого контракта. Эмитится AllowanceRevoked.

Контроль доступа / экстренные действия:

  • pause() / unpause() — экстренная остановка. Cancel и claimNativeRefund продолжают работать на паузе (защита средств maker'а).
  • emergencyWithdraw(address token, uint256 amount) — owner забирает ERC20 в свой адрес. Используется для застрявших токенов; эмитится EmergencyWithdrawn.
  • emergencyWithdrawNative(uint256 amount) — то же для native. Эмитится EmergencyWithdrawnNative.

Конструктор

constructor(address initialOwner, address _feeRouter);

Реверт ZeroAddress, если initialOwner == address(0) или _feeRouter == address(0). Сразу же выставляет _transferOwnership(initialOwner) и feeRouter = IFeeRouter(_feeRouter). Дальнейшая настройка (тюнинг параметров, передача ownership на multisig) — через admin-функции.

Безопасность

УгрозаМитигация
Reverting maker (DoS receive)_refundMaker ограничен MAKER_REFUND_GAS = 5_000; на fail сумма уходит в pendingNativeRefunds (pull-payment), RefundDeferred событие.
Out-of-gas в batchПеред каждой итерацией проверяется gasleft() >= minOrderGas; на нехватке цикл break-ится, эмитится BatchTruncated.
Gas-bomb (один «злой» ордер съел весь газ batch'а)Тот же minOrderGas + try/catch вокруг feeRouter.swap — если своп зареверт-ил весь оставшийся газ, последующие ордера всё равно либо отрабатывают, либо batch завершается через BatchTruncated.
MEV (сторонний бот перехватит)Для maker'а нейтрально — он получает effectiveMin либо лучше. Для keeper'а — рекомендуется private mempool / Polygon Merkle RPC.
ReentrancyВсе state-mutating maker/executor функции nonReentrant. Статус → Executed ставится до возврата из feeRouter.swap.
FeeRouter migrationsetFeeRouter + revokeAllowance — старый router теряет доступ к токенам, проходящим через контракт.
Frontrunning placeOrderРазмер amountIn и targetAmountOut фиксируются в storage; цена не зависит от пула на момент исполнения (есть effectiveMin).
Storage layoutИммютабельный контракт, апгрейда нет. При миграции — деплой нового контракта; v1 пауз-ится, makers cancel-ят свои ордера.
Capped payout (gas spike)См. отдельный раздел: ордер исполняется, executor берёт ≤ gasDeposit, риск asymmetric loss минимизирован vs. v1.

Типовой сценарий использования

  1. Maker определяет параметры в UI: amountIn = 1000 USDC, targetAmountOut = 0.4 ETH, slippageBps = 100 (1%), expiry = now + 24h.
  2. Maker: USDC.approve(limitOrderBook, 1000e6).
  3. Maker: limitOrderBook.placeOrder(USDC, WETH, 500, 1000e6, 0.4e18, 100, now + 86400) { value: 0.3 POL }.
  4. Бэкенд индексирует OrderPlaced и кладёт ордер в БД.
  5. Keeper каждые ~3 секунды (один блок Polygon):
    • Берёт все Open ордера с expiry > now.
    • Для каждого через Uniswap V3 QuoterV2 считает amountOut и сравнивает с effectiveMin.
    • Собирает batch из «созревших» ордеров (например, 20 шт), отсекая убыточные по газу.
    • Шлёт executeOrders([...]).
  6. При успехе:
    • Maker получает tokenOut напрямую от Uniswap.
    • Партнёры maker'а получают свою долю комиссии (через FeeRouter).
    • Executor получает gasCost + executorTip (или gasDeposit целиком при capped payout).
    • Остаток газ-депозита возвращается maker'у. Если receive() у maker'а сломан — уходит в pendingNativeRefunds; maker позже забирает через claimNativeRefund().
  7. Если до expiry ни один keeper не смог исполнить ордер — отдельный cron вызывает expireOrder(orderId). amountIn возвращается maker'у, caller получает expireTip.

Migration v1 → v2

  1. Pause v1. Owner-multisig вызывает LimitOrderBookV1.pause(). После этого новые placeOrder и executeOrder блокируются; cancelOrder продолжает работать.
  2. Makers cancel. Фронт показывает баннер «v2 готов к запуску, пожалуйста, отмените открытые ордера и пересоздайте после миграции». Активные maker'ы вызывают cancelOrder(orderId), забирают amountIn и gasDeposit.
  3. Прогон cleanup. По истечении grace-периода owner вызывает emergencyWithdraw для оставшихся «потерянных» токенов (если такие будут).
  4. Deploy v2. Деплой LimitOrderBook v2 с тем же feeRouter. Никаких apprivol со стороны maker'ов на новый контракт автоматически нет — фронт инициирует новые approve при первом размещении ордера на v2.
  5. Переключение фронта. В apps/frontend/src/config/contracts.ts адрес limitOrderBook меняется на v2. Фронт перестаёт показывать v1 ордера в «My Orders» (или показывает их в read-only режиме «архив»).
  6. Переключение бэка. Keeper-сервис меняет адрес и ABI на v2. Старый indexer оставляется в read-only до полной утилизации.

Открытые риски / out of scope

  • Pool fee tier фиксируется в placeOrder. Если ликвидность мигрирует в другой fee tier — ордер станет неисполнимым (SwapFailed). Решение: maker делает cancelOrder и пересоздаёт. UI должен показывать самый ликвидный tier на момент размещения.
  • Fee config FeeRouter может измениться после placeOrder. Это by design: maker берёт это на себя. Эффективный amountOut зависит от текущего fee, а effectiveMin — нет; следовательно, увеличение fee только увеличивает шанс SwapFailed, но не приведёт к получению меньшей суммы, чем ожидал maker.
  • Multi-hop swap не поддерживается. Single-hop через Uniswap V3 exactInputSingle. Если требуется multi-hop (например, USDT → WMATIC → WETH) — это отдельная фича, требующая расширения и FeeRouter, и LimitOrderBook (новые поля path в Order).
  • MEV-устойчивость не гарантируется. Контракт не использует commit-reveal или private mempool; защищает только effectiveMin. Sandwich-атаки в худшем случае «прожимают» цену до effectiveMin, что эквивалентно тому, что maker сам выбрал такой слиппаж.
  • Расход газа на 1 ордер при batch'е примерно 200-250k (зависит от пула). Для очень больших batch'ев (maxBatchSize = 500) реальное число обработанных ордеров будет упираться в block gas limit Polygon (30M); ориентируйтесь на BatchTruncated события из мониторинга.