Kerf is a first release and may still have small bugs. Our team is building it around the clock - follow updates and new features onEarly release - small bugs possible. Updates onx.com/UseKerf
Kerf
Chapter 03 · Range orders

Range orders

A limit order made of liquidity: place, the fill rule, cancel, and who gets paid what.

A range order is a limit order made of liquidity. RangeOrders.place mints a single-sided Uniswap v3 position parked entirely on one side of the price and holds it; as the price moves through the range the pool converts the principal into the other token and pays the position trading fees for doing it. The fact not to miss: an order is fillable only once the tick has moved fully past the range, not merely into it, and from then on anyone may call fill and keep KEEPER_BOUNTY_BPS (100, one percent) of the output. Until then the owner, and only the owner, may cancel at any time and take everything back. There is no admin, no upgrade path, and nothing in the contract can move an order's funds anywhere except to its owner, the filler and the FeeRouter.

What an order is

On an order book a resting limit order does nothing until it trades. Here the resting order is real liquidity: a position whose whole range sits above the price holds only token0, and the only way the pool can consume it is by buying token0 from you as the price rises through the range - a sell limit. A position whose range sits at or below the price holds only token1, and the pool consumes it by selling token0 to you as the price falls - a buy limit. Every swap that touches the range pays the position its share of the pool fee, so an order earns while it waits.

The position NFT is minted to the contract, never to you, so a fill can unwind it without an approval. Ownership lives only in Order.owner, and an order cannot be transferred. The terminal builds the band one tick spacing wide (limitBand in lib/trade/limit.ts), which is the closest a range can get to a single price; the contract accepts any width as long as the side rule below holds.

Placing an order

  1. 01
    Check the parameters
    block.timestamp > deadline is Deadline; amountIn == 0 is ZeroAmount; token0 >= token1 or tickLower >= tickUpper is BadRange. Unlike the zap, the tokens must already be in pool order. factory.getPool returning zero is NoPool.
  2. 02
    Check the side
    The pool's current tick is read from slot0. Selling token0 (zeroForOne) needs tickLower > tick; selling token1 needs tickUpper ≤ tick. Anything else straddles the price, would take both tokens and would not be a limit order: BadRange.
  3. 03
    Pull the sold token
    With msg.value attached the sold token must be WETH and the value must equal amountIn (ZeroAmount otherwise), and it is wrapped; with none, safeTransferFrom. Selling anything but WETH-as-ETH needs one exact-amount approval to the contract first.
  4. 04
    Mint to the contract
    npm.mint with the whole amountIn as the desired amount on the sold side, zero on the other, amount0Min and amount1Min both zero, and recipient = address(this). The minimums are zero because the unused side is zero by construction - the range cannot reach the price - so there is nothing to bound; the allowance is set to the exact amount and cleared after.
  5. 05
    Refund the dust
    Whatever the mint could not use of amountIn is sent straight back to msg.sender. The order records amountIn = used, the principal the position actually holds.
  6. 06
    Store and emit
    Ids start at 1 and count up (nextOrderId). The Order is written with status = Open and placedAt = block.timestamp, and Placed(orderId, owner, tokenId, zeroForOne, tickLower, tickUpper, amountIn) is emitted.
Range order lifecycleSelling token0 needs a range strictly above the price; selling token1 one at or below it. An order is Open until fill (anyone, after the cross) or cancel (owner, any time); there is no path back.
yesnoowneryesnoRangeOrders.place(params)Pushing the pricefill pays 1 % of the output, so akeeper may push the price throughthe range itself. That is the sametrade any arbitrageur could make,and only at or beyond the pricethe owner asked for.deadline · amount · tokens · pooltoken0 < token1 · lower < upperDeadline · ZeroAmountBadRange · NoPoolBadRange · wrong sideBadRange · wrong sidezeroForOne?which token is soldsell token0 · aboveneeds tickLower > tickstrictly above the pricesell token1 · belowneeds tickUpper ≤ tickat or below the pricepull the sold tokenmsg.value only when selling WETHZeroAmount (value ≠ amountIn)Why the strict inequalitiesLive on [lower, upper): at its lowertick it may hold both tokens, at itsupper it is all token1. Sellingtoken0 needs lower > tick; sellingtoken1 needs upper ≤ tick.npm.mint → this contractamount0Min/1Min = 0 · unused side is 0ETH in, WETH outplace takes ETH when the sold token isWETH; refund, fill and cancel all payWETH, because the recipient of a fillis not its caller.refund dust · Order.OpenPlaced(orderId, owner, tokenId, …)NotOwner · NotOpenNotOpen · NotFillablefillable?sell0: tick ≥ tickUppersell1: tick < tickLowercancel(orderId) · ownerany time, in range or notno bounty · 10 % of feesprincipal back wholefill(orderId) · anyoneunwind · 1 % of output → caller10 % of earned fees → FeeRouterrest → owner · burn NFTno · keeps earning feesCancelled(orderId, amt0, amt1)Filled(orderId, keeper, …)

The two side rules

place enforces exactly two rules, one per side, and fillable is their mirror image:

SellingzeroForOneplace requiresfillable when
token0 (range above the price)truetickLower > ticktick >= tickUpper
token1 (range at or below the price)falsetickUpper <= ticktick < tickLower

The asymmetry is not a typo. A Uniswap position is live on the half-open interval [tickLower, tickUpper): at tick == tickLower the price sits inside the range's lowest tick and the position may hold both tokens, while at tick == tickUpper the price has left the range and the position is all token1. So a range that is "above the price" must start strictly above the current tick - tickLower > tick, otherwise the mint would want some token1 - whereas a range "below the price" may end exactly at it, tickUpper <= tick, because a position whose upper tick equals the current tick is already entirely token1. The same interval says when an order is done: a sell-token0 order has been fully converted once tick >= tickUpper, but a sell-token1 order is not fully token0 until the tick is strictly below tickLower, since at tick == tickLower the range is live again and may still be holding a sliver of token1.

The terminal thinks in asset prices rather than ticks. A sell above the market is a band above the current tick when the asset is token0 and a band below it when the asset is token1, because the price is then a reciprocal; limitBand handles the mirror, snaps the limit price to the spacing, and nudges the band by one spacing if snapping landed it on the wrong side. It refuses a sell limit at or below the current price and a buy limit at or above it before anything is sent, and the contract re-checks both against the pool's current tick when the transaction lands.

The fill rule

An order is fillable once the price has crossed the whole band, not just entered it. A partially traversed range is a partially filled order, and unwinding one would hand back a mix of both tokens rather than the thing you asked for. Crossing fully is unambiguous: the position is one hundred percent the bought token, and fill can burn it and pay out cleanly.

fillable(orderId) is a view that returns false for anything not Open and otherwise applies the inequality above to slot0. fill calls it and reverts NotFillable on a no. There is no time component and no expiry: an order that never crosses stays open, earning, until its owner cancels it.

What fill does

fill is permissionless. It marks the order Filled first, then:

  1. Unwinds the position. _unwind reads the position's live liquidity from npm.positions, not the figure recorded at place. increaseLiquidity is permissionless on the position manager, so anyone can add dust to a position this contract holds; decreasing only the recorded amount would leave that donation behind, burn reverts unless the position is empty, and the owner's principal would be locked for the price of a few wei. Reading the live value unwinds the donation too, hands it to the owner and leaves the griefer out of pocket. decreaseLiquidity returns the principal alone; collect then pays that principal plus every fee the position accrued, so the fee portion is exactly collect − decrease (the position manager only folds fees into tokensOwed during the decrease, so reading it beforehand would see zero). Then burn.
  2. Sizes the bounty. amountOut is the bought-side total less the bought-side fees - the principal converted, before anything is taken from it - and bounty = amountOut × KEEPER_BOUNTY_BPS / 10 000.
  3. Pays the protocol. PROTOCOL_FEE_BPS (1000, ten percent) of the fee portion on each token goes to the FeeRouter with take(token, protocol, owner), so a referrer bound by the owner is credited. Principal is never touched.
  4. Pays the filler and the owner. The bounty comes out of the owner's bought-side share and goes to msg.sender; the rest of both tokens goes to owner.
  5. Emits Filled(orderId, keeper, amountOut, feesEarned, bounty).

feesEarned is reported in the bought token only. Fees accrue on the input side of each swap, and the swaps that cross an order are inputting exactly the token the order is buying, so the sold-side fee portion is dust from any brief reversal. _settleFees still takes its ten percent of that dust - the split runs on both tokens - but the event does not report it.

Cancel

cancel(orderId) is the owner's unconditional exit: any time the order is Open, in range or out of it, before or after the cross. It runs the same unwind and the same fee settlement - the protocol still takes ten percent of whatever the position earned while it waited - and pays everything else to the owner in whatever mix of the two tokens the position holds at that moment. There is no bounty, because you are unwinding your own position and there is nobody to pay. A cancelled order that never traded returns its principal minus only the pool's own rounding; one cancelled mid-crossing returns part principal and part conversion, which is the honest state of a half-filled limit. Cancelled(orderId, amount0, amount1) reports what the owner received.

Status is Open, Filled or Cancelled, and there is no path back to Open. A second fill or cancel on a closed order is NotOpen.

ETH in, WETH out

place accepts native ETH as a convenience when the sold token is WETH: attach msg.value == amountIn and _pull wraps it. Everything the contract pays out is ERC-20. The dust refund at place, the proceeds of a fill and the proceeds of a cancel all settle in WETH, because the recipient of a fill is the order's owner and not the caller, and unwrapping would mean handing an arbitrary address an ETH transfer that it may not be able to receive. The terminal's Limit panel pays in ETH automatically when you sell WETH and needs a one-step approval otherwise.

Parameters

PlaceParams
ParameterRangeDefaultMeaning
token0lower-sorted pool token-Must be below token1 as an address, or BadRange. Not reordered for you.
token1higher-sorted pool token-With token0 and fee, resolves the pool through the factory (NoPool if absent).
fee500 · 3000 · 10000 … hundredths of a bip-The pool fee tier.
zeroForOnetrue · false-true sells token0 into a range above the price; false sells token1 into a range at or below it.
tickLower> tick when selling token0 ticks-Lower tick of the range. One spacing below tickUpper in the terminal.
tickUpper≤ tick when selling token1 ticks-Upper tick of the range.
amountIn> 0 wei of the sold token-The principal. Equals msg.value when paying in ETH. Whatever the mint cannot use is refunded.
deadlineunix secondsnow + 600 in the terminalLatest block timestamp the call may execute at; also passed to the position manager.

orders(orderId) returns the stored Order:

FieldMeaning
ownerWho placed the order and receives the proceeds. The only notion of ownership; the NFT is the contract's.
tokenIdThe single-sided position NFT held by the contract.
poolThe pool the order lives in.
zeroForOnetrue when the order sells token0 for token1.
tickLower, tickUpperThe range.
liquidityLiquidity minted at place. Informational: the unwind reads the live figure.
amountInThe sold-token principal the position actually holds, after the dust refund.
placedAtBlock timestamp of place.
statusOpen, Filled or Cancelled.

Fees and bounties

FlowRateTo
The converted principal on a fillKEEPER_BOUNTY_BPS = 100 (1 %)whoever called fill
The fees the position earned, on a fill or a cancelPROTOCOL_FEE_BPS = 1000 (10 %) of the fee portion of each tokenFeeRouter, 20 % of it onward to the owner's referrer
Everything else-the owner

The one percent is what makes the order fill at all. A keeper only submits when the bounty is worth more than the gas; the ten percent is the same protocol share the keeper and the vaults take on a harvest, and it applies only to earned fees, never to principal. There is no fee on placing and no fee on cancelling beyond that share of what was earned; the whole table is in Fees & referrals.

Who calls fill

In practice the keeper bot does. Each tick it reads fillable for every open order in one Multicall3 batch, prices the bounty as one percent of the order's size in USD, converts it to wei with the ETH mark, and submits fill only when that is at least GAS_BOUNTY_MULTIPLE (3) times the estimated gas; if the bounty cannot be priced the action is skipped, not sent blind. An order that is deep out of the money with a small output may therefore sit past its crossing until gas is cheap, or until someone else - you, or anyone - fills it. The terminal shows the estimated daily fee income beside a resting order (earnsPerDayUsd, the optimistic "if the market sits on your order" figure), so the waiting has a number on it.

Because fill pays one percent of the output to whoever calls it, a keeper can profitably push the price through the range itself and then fill. That is fine, and it is fine on purpose: pushing the price through the range is exactly the trade any arbitrageur could already make against the position, it pays the position its fees on the way, and it can only ever happen at or beyond the price the owner asked for - the fill rule is a tick, not a signature. What a filler cannot do is fill early, fill partially, or send the proceeds anywhere but to Order.owner.

Errors

ErrorWhen
Deadlineblock.timestamp > deadline on place.
ZeroAmountamountIn == 0; or ETH attached while the sold token is not WETH; or msg.value != amountIn.
BadRangetoken0 >= token1; tickLower >= tickUpper; or the range is on the wrong side of the current tick for the side being sold.
NoPoolNo pool at (token0, token1, fee).
NotOpenfill or cancel on an order that is already Filled or Cancelled.
NotFillablefill before the tick has crossed the whole range.
NotOwnercancel by anyone but Order.owner.

Events

EventFields
PlacedorderId (indexed), owner (indexed), tokenId, zeroForOne, tickLower, tickUpper, amountIn (the principal after the dust refund)
FilledorderId (indexed), keeper (indexed), amountOut (principal converted, before the bounty), feesEarned (in the bought token), bounty
CancelledorderId (indexed), amount0, amount1 (what the owner received)

The indexer folds them into the rangeOrder table the portfolio and the keeper bot read.

What it does not do

  • It is not an order book. There is no price-time priority, no partial fill and no matching; the pool fills it, one swap at a time, as the price passes.
  • It does not fill promptly, or at all, on its own. Someone has to call fill, and the bounty is what pays them; an order that crosses and comes back before anyone fills it keeps earning and waits for the next cross.
  • It does not expire. Cancel it or it stays.
  • It cannot be amended or transferred. Cancel and place again.
  • It pays no ETH. Everything out is WETH, including the refund at place.
  • It never touches principal for a fee. The ten percent is on earned fees only, and the one percent bounty is taken from the conversion the owner asked for, at or beyond their price.
Read the code
  • contracts/src/RangeOrders.sol place, fillable, fill, cancel, _unwind, _settleFees
  • contracts/test/RangeOrders.t.sol both sides, the cross, the split, native ETH, and the dust-donation griefing case
  • apps/web/lib/trade/limit.ts limitBand - the one-spacing band and the side rules, and earnsPerDayUsd
  • apps/web/app/(terminal)/trade/[asset]/LimitPanel.tsx the Limit tab: place with ETH or an approval, slot0 before anything is sent
  • apps/api/src/bots/keeper.ts sweepRangeOrders - fillable via Multicall3, the bounty in wei
  • apps/api/src/bots/chain.ts GAS_BOUNTY_MULTIPLE and the skip rule