This describes a pre-release version of LBAMM. Interfaces and behavior may change.
Verify the exact repository commit before building production integrations.
CLOB Transfer Handler
The CLOBTransferHandler is a transfer handler that implements an onchain central-limit order book (CLOB) and settles AMM swaps by filling CLOB orders.
Conceptually:
- Order makers deposit tokens into the handler and open limit orders at a price.
- When an AMM swap uses the CLOBTransferHandler, the AMM sends the swap’s output token to the handler.
- The handler uses that output to fill maker orders (crediting makers internally), and then transfers the required input token back to the AMM to settle the swap.
This page documents:
- How the CLOB integrates with LBAMM swap settlement
- Order book identity (orderBookKey / groupKey)
- Maker lifecycle (deposit → open → close → withdraw)
- Fill path via
ammHandleTransferand refund semantics - Hook and token-hook validation surfaces
- Events and how indexers can maintain derived state
Deep details (order matching math, rounding, and onchain data structures) are described at the level needed for integrators to safely use and index the system.
Key design constraints
Recipient must be the handler
Swaps settled through the CLOBTransferHandler must set:
swapOrder.recipient == address(CLOBTransferHandler)
The handler requires custody of the AMM’s output tokens in order to:
- credit filled makers’ balances in the output token, and
- retain those funds in the handler so makers can withdraw later or open new orders in the opposite direction.
Output-based swaps are not supported
CLOB settlement only supports input-based swaps (swapOrder.amountSpecified >= 0).
If an output-based swap is attempted, ammHandleTransfer reverts.
Order books: orderBookKey and groupKey
Each order book is uniquely identified by an orderBookKey:
orderBookKey = keccak256(tokenIn, tokenOut, groupKey)
Where groupKey encodes:
- a CLOB hook address
- a minimum order size (base + scale)
groupKey layout
bytes32 groupKey = bytes32(uint256(uint160(hook)) << 96)
| bytes32(uint256(minimumOrderBase) << 8)
| bytes32(uint256(minimumOrderScale));
Helpers exist to decode:
hookminimumOrder = minimumOrderBase * 10^minimumOrderScale
minimumOrderScaleis bounded (max 72) to avoid overflow.
Initializing orderBookKey metadata
Order books are lazily initialized on first use, but an explicit helper exists:
initializeOrderBookKey(tokenIn, tokenOut, hook, minimumOrderBase, minimumOrderScale)
Once initialized, a public mapping exposes the decoded parameters:
orderBookKeys[orderBookKey] -> OrderBookKey
Pricing model
Orders are placed at a price represented as sqrtPriceX96 (Q96 fixed point):
- Price is encoded as
sqrt(price) * 2^96
The handler computes tokenOut required for a given tokenIn at a price via:
function calculateFixedInput(uint256 amountIn, uint160 sqrtPriceX96)
internal pure returns (uint256 amountOut)
{
amountOut = FullMath.mulDivRoundingUp(amountIn, sqrtPriceX96, Q96);
amountOut = FullMath.mulDivRoundingUp(amountOut, sqrtPriceX96, Q96);
}
Notes:
- This effectively applies the squared price (in Q96) with rounding up.
orderAmountis always denominated in tokenIn.
“Best price for taker”
When filling, the handler fills starting from the best price for the taker (the AMM swap executor):
- the price that yields the most tokenIn for the least tokenOut
At a given price level, orders fill FIFO.
Maker lifecycle
Deposit
Makers can pre-fund balances:
depositToken(token, amount)
The handler tracks balances in:
makerTokenBalance[token][maker]
A deposit:
- transfers tokens into the handler
- increments the maker’s internal balance
- emits
TokenDeposited
Withdraw
Makers can withdraw available balances:
withdrawToken(token, amount)
A withdraw:
- decrements the maker’s internal balance
- transfers tokens out
- emits
TokenWithdrawn
Non-standard ERC-20 behavior (e.g., rebasing or external balance adjustment) can break the “1:1 backing” assumption and cause drift between internal balances and the handler’s actual token balances.
Open a limit order
Makers open orders via:
openOrder(tokenIn, tokenOut, sqrtPriceX96, orderAmount, groupKey, hintSqrtPriceX96, hookData)
Key behaviors:
orderAmountis funded from existing internal balance; if insufficient, the handler attempts totransferFromthe shortfall from the maker.orderAmountmust be ≥minimumOrder(groupKey).- If the group hook is non-zero, the handler calls
ICLOBHook.validateMaker(...). - If token hooks have handler-order validation enabled, the handler calls
tokenHook.validateHandlerOrder(...)for tokenIn and/or tokenOut. - The order is inserted into the per-price FIFO queue.
Close an order
Makers close an order via:
closeOrder(tokenIn, tokenOut, sqrtPriceX96, orderNonce, groupKey)
Closing:
- marks the order closed
- returns the unfilled tokenIn amount back to the maker’s internal balance
- emits
OrderClosed
Swap settlement via ammHandleTransfer
When a swap uses the CLOBTransferHandler, the AMM calls:
ammHandleTransfer(executor, swapOrder, amountIn, amountOut, exchangeFee, feeOnTop, transferExtraData)
Preconditions enforced by the handler
The handler reverts unless:
msg.sender == AMMtransferExtraData.length > 0swapOrder.recipient == address(this)swapOrder.amountSpecified >= 0(input-based)
transferExtraData format
transferExtraData must decode as:
struct FillParams {
bytes32 groupKey;
uint256 maxOutputSlippage;
bytes hookData;
}
The handler derives the order book:
orderBookKey = hash(tokenIn, tokenOut, groupKey)
Taker (executor) validation via group hook
If groupKey encodes a non-zero hook, the handler calls:
ICLOBHook(hook).validateExecutor(orderBookKey, executor, swapOrder, amountIn, amountOut, exchangeFee, feeOnTop, hookData)
This is the CLOB’s primary surface for validating takers/executors.
Fill algorithm (high-level)
The handler calls into the matching engine:
CLOBHelper.fillOrder(orderBook, makerTokenBalance[tokenOut], amountIn, amountOut)
Filling behavior:
- Orders fill from the current best price upward until the required
amountInis fully sourced. - At each price level, orders fill FIFO.
- As each maker order consumes
tokenOut, the maker’s internal balance intokenOutis incremented.
The helper returns:
fillOutputRemaining: amount of AMM outputtokenOutnot needed to fill the required inputendingOrderNonce,endingOrderInputRemaining: head-of-book details after filling
The handler emits:
OrderBookFill(orderBookKey, endingOrderNonce, endingOrderInputRemaining)
Why “output remaining” exists
An AMM swap can produce more tokenOut than the CLOB needs to source the required amountIn. This typically happens when:
- the CLOB’s available prices have moved materially since the swap was constructed, so fewer orders (or better prices) are available than implied by the caller’s assumptions.
Slippage bound for unused output
If fillOutputRemaining > 0, the handler compares it to maxOutputSlippage:
- if
fillOutputRemaining > maxOutputSlippage: revert - otherwise: the handler schedules a refund of
fillOutputRemainingto the executor
This parameter bounds how much “unused output” the executor is willing to tolerate before re-submitting with updated assumptions.
Refund callback after swap finalization
If a refund is required, the handler returns callback data:
afterSwapRefund(executor, tokenOut, fillOutputRemaining)
After swap finalization, the AMM executes this callback on the handler.
Refund behavior:
- For wrapped native, the handler attempts to unwrap and send native value to the executor; if unwrap fails it transfers wrapped native.
- For ERC-20, it transfers the token directly.
Settling the AMM
After filling, the handler transfers the required input token to the AMM:
safeTransfer(tokenIn, AMM, amountIn)
If the transfer fails, the handler reverts.
Validation surfaces
CLOB group hook (ICLOBHook)
A group hook can enforce:
- maker eligibility and constraints at order open
- executor/taker eligibility and constraints at fill time
Interface:
interface ICLOBHook is ITransferHandlerExecutorValidation {
function validateMaker(
bytes32 orderBookKey,
address depositor,
uint160 sqrtPriceX96,
uint256 orderAmount,
bytes calldata hookData
) external;
}
Token hook validateHandlerOrder (order open only)
When opening an order, the handler checks token settings in the AMM:
- if
TOKEN_SETTINGS_HANDLER_ORDER_VALIDATE_FLAGis enabled for tokenIn and/or tokenOut, it calls the token hook:
validateHandlerOrder(
maker,
hookForTokenIn,
tokenIn,
tokenOut,
amountIn,
amountOut,
handlerOrderParams,
hookData
)
For CLOB orders, handlerOrderParams is:
abi.encode(orderBookKey, sqrtPriceX96)
This hook is invoked only on order open (not during fills or closes).
Internal order identifiers
Within a price bucket, FIFO ordering uses an internal orderId derived from the order’s storage slot.
function _orderToOrderId(Order storage ptrOrder) internal pure returns (bytes32 orderId) {
assembly ("memory-safe") { orderId := ptrOrder.slot }
}
function _orderIdToOrder(bytes32 orderId) internal pure returns (Order storage ptrOrder) {
assembly ("memory-safe") { ptrOrder.slot := orderId }
}
This is an internal implementation detail for maintaining linked lists; integrators should treat orderNonce as the public identifier for maker actions.
Events and indexing
CLOBTransferHandler is designed to be indexable using events. An indexer can maintain derived state for:
- maker balances (by token)
- open orders per order book and price
- order book head pointer (best price and next order)
Event reference
event TokenDeposited(address indexed token, address indexed depositor, uint256 amount);
event TokenWithdrawn(address indexed token, address indexed depositor, uint256 amount);
event OrderBookInitialized(
bytes32 indexed orderBookKey,
address tokenIn,
address tokenOut,
address hook,
uint16 minimumOrderBase,
uint8 minimumOrderScale
);
event OrderOpened(
address indexed maker,
bytes32 indexed orderBookKey,
uint256 orderAmount,
uint160 sqrtPriceX96,
uint256 orderNonce
);
event OrderClosed(
address indexed maker,
bytes32 indexed orderBookKey,
uint256 unfilledInputAmount,
uint256 orderNonce
);
event OrderBookFill(
bytes32 indexed orderBookKey,
uint256 endingOrderNonce,
uint256 endingOrderInputRemaining
);
Suggested indexing strategy
Discover order books
Listen for OrderBookInitialized(orderBookKey, ...).
- This yields the canonical
tokenIn,tokenOut,hook, and minimum-size parameters for the order book. - Order books may also be created lazily; if you observe
OrderOpened/OrderBookFillfor an unknown key, you can queryorderBookKeys[orderBookKey]to resolve metadata (if initialized).
Track maker balances (derived)
For a best-effort derived ledger:
- On
TokenDeposited(token, maker, amount):balance[token][maker] += amount - On
TokenWithdrawn(token, maker, amount):balance[token][maker] -= amount
Order events imply additional balance movements:
-
On
OrderOpened(maker, orderBookKey, orderAmount, ...):tokenIn = orderBookKeys[orderBookKey].tokenInbalance[tokenIn][maker] -= orderAmount- (Note: the handler may have pulled additional tokens from the maker if their internal balance was insufficient. That extra pull emits
TokenDeposited(tokenIn, maker, depositRequired)insideopenOrder.)
-
On
OrderClosed(maker, orderBookKey, unfilledInputAmount, orderNonce):tokenIn = orderBookKeys[orderBookKey].tokenInbalance[tokenIn][maker] += unfilledInputAmount
-
On
OrderBookFill(orderBookKey, ...):- makers’
tokenOutbalances are credited internally as fills occur. - The handler does not emit per-maker fill events in this version. An indexer cannot attribute fills to individual makers using events alone.
- makers’
Because fills are aggregated, a purely event-driven indexer can still infer which orders were fully filled by combining:
OrderOpened(which providessqrtPriceX96andorderNonce), andOrderBookFill(orderBookKey, endingOrderNonce, endingOrderInputRemaining)(which provides the new head order)
Since the ending head price can be resolved from the maintained order book state (see below), after each OrderBookFill an indexer can conclude:
- all price levels strictly better than the ending head price have been fully consumed, and
- within the ending head price bucket, all orders with nonces lower than
endingOrderNoncehave been fully consumed, and - the head order at
endingOrderNoncehasendingOrderInputRemainingremaining.
Empty book sentinel: when both endingOrderNonce and endingOrderInputRemaining are zero, the order book has been completely cleared by the fill.
This allows an event-driven indexer to deterministically attribute fills to makers: all orders strictly before the ending head (by price, then FIFO nonce within price) are fully filled, and the ending head order—if non-zero—represents the only partially filled order in that bucket.
Track open orders
From events alone:
- You can track the set of opened orders by
(orderBookKey, maker, orderNonce, sqrtPriceX96, orderAmount)usingOrderOpened. - You can mark an order closed using
OrderClosed(maker, orderBookKey, ..., orderNonce).
However, you cannot infer partial fills from events alone. To present “remaining size” for an open order, an indexer must query onchain storage or rely on future events.
Track head-of-book (best price)
OrderBookFill(orderBookKey, endingOrderNonce, endingOrderInputRemaining) provides the new head order after a fill.
To interpret it fully, the indexer needs the ending head price. There are two practical approaches:
-
Event + offchain reconstruction (recommended): maintain per-order-book price buckets from
OrderOpenedand advance the “best price” pointer deterministically as fills occur.- Since the book fills FIFO at the current best price and then moves monotonically to the next worse price until the required input is sourced, the head price after a fill is the lowest price that still has remaining orders.
-
Onchain query: query
orderBookKeys[orderBookKey]for metadata and read the handler state if you maintain a stateful indexer that can access contract storage.
Integration notes
- Swaps settled via CLOB must set the recipient to the handler.
- Only input-based swaps are supported.
maxOutputSlippagebounds how much unused AMM output can be refunded rather than consumed.- Token hook
validateHandlerOrderis invoked only when opening orders, and only if enabled in token settings.
