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
- 01Check the parameters
block.timestamp > deadlineisDeadline;amountIn == 0isZeroAmount;token0 >= token1ortickLower >= tickUpperisBadRange. Unlike the zap, the tokens must already be in pool order.factory.getPoolreturning zero isNoPool. - 02Check the sideThe pool's current tick is read from
slot0. Selling token0 (zeroForOne) needstickLower > tick; selling token1 needstickUpper ≤ tick. Anything else straddles the price, would take both tokens and would not be a limit order:BadRange. - 03Pull the sold tokenWith
msg.valueattached the sold token must be WETH and the value must equalamountIn(ZeroAmountotherwise), and it is wrapped; with none,safeTransferFrom. Selling anything but WETH-as-ETH needs one exact-amount approval to the contract first. - 04Mint to the contract
npm.mintwith the wholeamountInas the desired amount on the sold side, zero on the other,amount0Minandamount1Minboth zero, andrecipient = 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. - 05Refund the dustWhatever the mint could not use of
amountInis sent straight back tomsg.sender. The order recordsamountIn = used, the principal the position actually holds. - 06Store and emitIds start at 1 and count up (
nextOrderId). TheOrderis written withstatus = OpenandplacedAt = block.timestamp, andPlaced(orderId, owner, tokenId, zeroForOne, tickLower, tickUpper, amountIn)is emitted.
The two side rules
place enforces exactly two rules, one per side, and fillable is their mirror image:
| Selling | zeroForOne | place requires | fillable when |
|---|---|---|---|
| token0 (range above the price) | true | tickLower > tick | tick >= tickUpper |
| token1 (range at or below the price) | false | tickUpper <= tick | tick < 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:
- Unwinds the position.
_unwindreads the position's live liquidity fromnpm.positions, not the figure recorded atplace.increaseLiquidityis 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,burnreverts 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.decreaseLiquidityreturns the principal alone;collectthen pays that principal plus every fee the position accrued, so the fee portion is exactlycollect − decrease(the position manager only folds fees intotokensOwedduring the decrease, so reading it beforehand would see zero). Thenburn. - Sizes the bounty.
amountOutis the bought-side total less the bought-side fees - the principal converted, before anything is taken from it - andbounty = amountOut × KEEPER_BOUNTY_BPS / 10 000. - Pays the protocol.
PROTOCOL_FEE_BPS(1000, ten percent) of the fee portion on each token goes to theFeeRouterwithtake(token, protocol, owner), so a referrer bound by the owner is credited. Principal is never touched. - 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 toowner. - 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
| Parameter | Range | Default | Meaning |
|---|---|---|---|
token0 | lower-sorted pool token | - | Must be below token1 as an address, or BadRange. Not reordered for you. |
token1 | higher-sorted pool token | - | With token0 and fee, resolves the pool through the factory (NoPool if absent). |
fee | 500 · 3000 · 10000 … hundredths of a bip | - | The pool fee tier. |
zeroForOne | true · 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. |
deadline | unix seconds | now + 600 in the terminal | Latest block timestamp the call may execute at; also passed to the position manager. |
orders(orderId) returns the stored Order:
| Field | Meaning |
|---|---|
owner | Who placed the order and receives the proceeds. The only notion of ownership; the NFT is the contract's. |
tokenId | The single-sided position NFT held by the contract. |
pool | The pool the order lives in. |
zeroForOne | true when the order sells token0 for token1. |
tickLower, tickUpper | The range. |
liquidity | Liquidity minted at place. Informational: the unwind reads the live figure. |
amountIn | The sold-token principal the position actually holds, after the dust refund. |
placedAt | Block timestamp of place. |
status | Open, Filled or Cancelled. |
Fees and bounties
| Flow | Rate | To |
|---|---|---|
| The converted principal on a fill | KEEPER_BOUNTY_BPS = 100 (1 %) | whoever called fill |
| The fees the position earned, on a fill or a cancel | PROTOCOL_FEE_BPS = 1000 (10 %) of the fee portion of each token | FeeRouter, 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
| Error | When |
|---|---|
Deadline | block.timestamp > deadline on place. |
ZeroAmount | amountIn == 0; or ETH attached while the sold token is not WETH; or msg.value != amountIn. |
BadRange | token0 >= token1; tickLower >= tickUpper; or the range is on the wrong side of the current tick for the side being sold. |
NoPool | No pool at (token0, token1, fee). |
NotOpen | fill or cancel on an order that is already Filled or Cancelled. |
NotFillable | fill before the tick has crossed the whole range. |
NotOwner | cancel by anyone but Order.owner. |
Events
| Event | Fields |
|---|---|
Placed | orderId (indexed), owner (indexed), tokenId, zeroForOne, tickLower, tickUpper, amountIn (the principal after the dust refund) |
Filled | orderId (indexed), keeper (indexed), amountOut (principal converted, before the bounty), feesEarned (in the bought token), bounty |
Cancelled | orderId (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.
contracts/src/RangeOrders.solplace, fillable, fill, cancel, _unwind, _settleFeescontracts/test/RangeOrders.t.solboth sides, the cross, the split, native ETH, and the dust-donation griefing caseapps/web/lib/trade/limit.tslimitBand - the one-spacing band and the side rules, and earnsPerDayUsdapps/web/app/(terminal)/trade/[asset]/LimitPanel.tsxthe Limit tab: place with ETH or an approval, slot0 before anything is sentapps/api/src/bots/keeper.tssweepRangeOrders - fillable via Multicall3, the bounty in weiapps/api/src/bots/chain.tsGAS_BOUNTY_MULTIPLE and the skip rule