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 | Назначение |
|---|---|---|---|
feeRouter | IFeeRouter | — (constructor) | Текущий FeeRouter. |
minGasDeposit | uint256 | 0.3 ether | Минимум msg.value для placeOrder. |
maxExpiry | uint256 | 7 days | Максимальное «время жизни» ордера от момента размещения. |
executorTip | uint256 | 0.01 ether | Премия исполнителю при успешном executeOrder. |
expireTip | uint256 | 0.005 ether | Премия caller'у expireOrder. |
gasOverhead | uint256 | 50_000 | Добавляется к замеру gasleft() чтобы компенсировать финальные операции и события. |
maxSlippageBps | uint16 | 2_000 (20%) | Верхняя граница slippageBps, которую maker может указать. |
maxBatchSize | uint16 | 50 | Максимум ордеров в одном вызове executeOrders. Cap при setter'е — 500. |
minOrderGas | uint256 | 250_000 | Если gasleft() в батче падает ниже — цикл прерывается (см. BatchTruncated). |
nextOrderId | uint256 | 0 | Счётчик ID. Первый ордер получит orderId = 0. |
pendingNativeRefunds | mapping(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 или вообще не существует).Expired—block.timestamp > order.expiry. Контракт не меняет статус на Expired — это делает толькоexpireOrder. Соответственно, такой ордер навсегда «застрял» в Open, пока keeper не подберёт его черезexpireOrder.SwapFailed—feeRouter.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.
Ошибки
InsufficientGasDeposit—msg.value < minGasDeposit.InvalidExpiry—expiry <= nowилиexpiry > now + maxExpiry.InvalidAmount—amountIn == 0илиtargetAmountOut == 0.InvalidSlippage—slippageBps == 0илиslippageBps > maxSlippageBps. Также бросается изsetMaxSlippageBpsпри невалидном новом значении.PairNotAllowed— пара не в allowlist FeeRouter.NotOpen— статус ордера неOpen(вcancelOrder/topUpGas/expireOrder).NotMaker—cancelOrderвызван не maker'ом.NotExpired—expireOrderвызван до истеченияexpiry.AlreadyExpired—topUpGasвызван на ордере, у которого ужеblock.timestamp > expiry.TransferFailed—.call{value:}вернулfalse(executor payout / claimNativeRefund / emergencyWithdrawNative).ZeroAddress—address(0)передан туда, где не должен быть (constructor,setFeeRouter).EmptyBatch—executeOrdersвызвана с пустым массивом.BatchTooLarge— длина массива >maxBatchSize, либоsetMaxBatchSizeсvalue > 500илиvalue == 0.NoPendingRefund—claimNativeRefundбез накопленной суммы.
Функции 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);
Валидация по порядку:
msg.value >= minGasDeposit.expiry > now && expiry <= now + maxExpiry.amountIn != 0 && targetAmountOut != 0.slippageBps != 0 && slippageBps <= maxSlippageBps.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-исполнение. Шаги:
n = orderIds.length. Еслиn == 0→ ревертEmptyBatch. Еслиn > maxBatchSize→ ревертBatchTooLarge.- Цикл по массиву:
- Перед каждой итерацией проверяется
gasleft() >= minOrderGas. Если нет —break. Это страховка от gas-bomb атаки одного из ордеров (см. ниже). - Вызывается внутренний
_tryExecute(orderIds[i]). - На успех —
executedCount++. На любой soft-fail —OrderSkipped+skippedCount++.
- Перед каждой итерацией проверяется
- Если цикл прервался досрочно (
processed < n) — эмититсяBatchTruncated(msg.sender, n, processed). - В конце —
BatchExecuted(msg.sender, processed, executedCount, skippedCount).
Важно: дубликаты в массиве не реверт-ят весь батч. После первого успешного исполнения второй экземпляр того же orderId уйдёт в skip с reason = NotOpen.
Внутренняя _tryExecute
Реальная логика исполнения. Шаги:
gasStart = gasleft().- Soft-проверки: статус
Open,block.timestamp <= expiry. На fail возвращается соответствующийSkipReason. - Локально читаются
tokenIn,tokenOut,poolFee,amountIn,maker,deposit, и вычисляетсяeffectiveMin = targetAmountOut * (BPS_DENOMINATOR - slippageBps) / BPS_DENOMINATOR. _approveFeeRouter(tokenIn, amountIn)— ленивыйforceApprove(router, type(uint256).max)при недостатке allowance (с предварительным сбросом в 0 для USDT-like токенов).try feeRouter.swap(tokenIn, tokenOut, poolFee, amountIn, effectiveMin, maker /* recipient */, maker /* feePayer */):catch→(false, SkipReason.SwapFailed). Статус ордера не меняется, ничего не списано (FeeRouter откатываетsafeTransferFrom).
- После успешного свопа статус сразу →
Executed.amountInуже потрачен; точку невозврата прошли. gasUsed = gasStart - gasleft() + gasOverhead.payout = gasUsed * tx.gasprice + executorTip. См. раздел про capped payout —payoutкапается доdeposit._payExecutor(msg.sender, payout).- Если
deposit > payout—_refundMaker(maker, deposit - payout)(с pull-payment fallback). - Эмитится
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 migration | setFeeRouter + revokeAllowance — старый router теряет доступ к токенам, проходящим через контракт. |
| Frontrunning placeOrder | Размер amountIn и targetAmountOut фиксируются в storage; цена не зависит от пула на момент исполнения (есть effectiveMin). |
| Storage layout | Иммютабельный ко нтракт, апгрейда нет. При миграции — деплой нового контракта; v1 пауз-ится, makers cancel-ят свои ордера. |
| Capped payout (gas spike) | См. отдельный раздел: ордер исполняется, executor берёт ≤ gasDeposit, риск asymmetric loss минимизирован vs. v1. |
Типовой сценарий использования
- Maker определяет параметры в UI:
amountIn = 1000 USDC,targetAmountOut = 0.4 ETH,slippageBps = 100(1%),expiry = now + 24h. - Maker:
USDC.approve(limitOrderBook, 1000e6). - Maker:
limitOrderBook.placeOrder(USDC, WETH, 500, 1000e6, 0.4e18, 100, now + 86400) { value: 0.3 POL }. - Бэкенд индексирует
OrderPlacedи кладёт ордер в БД. - Keeper каждые ~3 секунды (один блок Polygon):
- Берёт все Open ордера с
expiry > now. - Для каждого через Uniswap V3 QuoterV2 считает
amountOutи сравнивает сeffectiveMin. - Собирает batch из «созревших» ордеров (например, 20 шт), отсекая убыточные по газу.
- Шлёт
executeOrders([...]).
- Берёт все Open ордера с
- При успехе:
- Maker получает
tokenOutнапрямую от Uniswap. - Партнёры maker'а получают свою долю комиссии (через FeeRouter).
- Executor получает
gasCost + executorTip(илиgasDepositцеликом при capped payout). - Остаток газ-депозита возвращается maker'у. Если
receive()у maker'а сломан — уходит вpendingNativeRefunds; maker позже забирает черезclaimNativeRefund().
- Maker получает
- Если до
expiryни один keeper не смог исполнить ордер — отдельный cron вызываетexpireOrder(orderId).amountInвозвращается maker'у, caller получаетexpireTip.
Migration v1 → v2
- Pause v1. Owner-multisig вызывает
LimitOrderBookV1.pause(). После этого новыеplaceOrderиexecuteOrderблокируются;cancelOrderпродолжает работать. - Makers cancel. Фронт показывает баннер «v2 готов к запуску, пожалуйста, отмените открытые ордера и пересоздайте после миграции». Активные maker'ы вызывают
cancelOrder(orderId), забираютamountInиgasDeposit. - Прогон cleanup. По истечении grace-периода owner вызывает
emergencyWithdrawдля оставшихся «потерянных» токенов (если такие будут). - Deploy v2. Деплой
LimitOrderBookv2 с тем жеfeeRouter. Никаких apprivol со стороны maker'ов на новый контракт автоматически нет — фронт инициирует новые approve при первом размещении ордера на v2. - Переключение фронта. В
apps/frontend/src/config/contracts.tsадресlimitOrderBookменяется на v2. Фронт перестаёт показывать v1 ордера в «My Orders» (или показывает их в read-only режиме «архив»). - Переключение бэка. 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события из мониторинга.