Velocity ProtocolDevelopers
Market Makers

Orderbook & Matching

The DLOB is to be removed. An order will rest in one CLOB market account instead of in the User account of its owner. The fill route already moves that way: a taker remainder that can rest goes onto the book, not into User.orders. This page describes the DLOB as it works now. New work belongs on the CLOB book. See PropAMM and CLOB Order Flow.

Where a resting order sits, and what beats it to a fill, decides whether it fills at all. This page covers the DLOB that holds maker quotes, the fill plan the program builds against them, and the three ways to read the book.

What is the DLOB?

The DLOB (decentralized limit order book) is Velocity's offchain orderbook. It aggregates the resting limit orders scattered across individual user accounts into one sorted bid/ask view for matching and price discovery, while the orders themselves stay onchain.

Storing a sorted book onchain would cost a write for every insertion and every reprice. Velocity instead stores orders in user accounts, up to 32 per subaccount, and the DLOB server reads all relevant UserAccount state from chain, pulls the active fillable limit orders out of each account, sorts them into bid and ask price levels, and publishes the result over WebSocket and HTTP. It never writes onchain state. It is a read-only projection.

That split is worth holding onto. Onchain is the source of truth: orders live in UserAccount, the program processes fills, and settlement, P&L, collateral, and margin checks all happen there. The DLOB is the derived view: sorted depth, aggregation, realtime updates, and a convenient API. If the DLOB server goes down, resting orders are still onchain, still valid, and still fillable. They stop appearing in the aggregated book until it recovers, and nothing else changes.

The server also filters what it publishes. It drops expired orders, honours order flags (post-only, reduce-only), and hides orders in markets that are not Active. Reduce-only order types are the exception: those stay visible in a ReduceOnly market too. See Market status.

How matching works

Taker fills are built by fulfill_perp_order in the perp order controller. It first asks determine_perp_fulfillment_methods for a fill plan, then executes that plan step by step through fulfill_perp_order_step. The plan is a list of PerpFulfillmentMethod values, and the type has exactly two variants: AMM(Option<u64>), whose payload is the price cap on that step and is None when the step is uncapped, and Match(Pubkey, u16, u64), carrying the maker's account, its order index, and the maker's price.

Makers are collected and sorted by price

get_maker_orders_info gathers the crossing maker orders and binary-inserts each one into price order, best price first for the taker: descending for a taker selling, ascending for a taker buying.

There is no time priority inside a price level. The program walks the maker accounts the filler passed in pubkey order, and an equal-priced candidate is inserted ahead of the one already in the list, so which of two makers quoting the same price fills first follows from their account addresses rather than from when either order was placed.

The plan walks that list in price order

For each maker in turn, the planner stops as soon as a maker no longer crosses the taker's limit price. Otherwise the maker becomes a Match step. The walk also stops once the plan holds more than six steps, so one taker order reaches a bounded number of makers however deep the book is, and the size beyond them falls to the residual AMM step or goes unfilled.

The AMM is inserted ahead of any maker it out-prices

Before each Match, the planner compares the maker's price against the AMM's current bid or ask (including spread and reference price offset). If the maker is not better than the AMM, an AMM step is inserted in front of that maker, capped at the maker's price, and the running AMM price is pulled to the maker's price for the next comparison. If the maker is better, the maker goes first and the AMM waits.

A residual AMM step closes out the plan

After the maker list is exhausted, if the taker still crosses the AMM price, one final uncapped AMM step absorbs whatever is left.

Size that no source has depth for stays unfilled. A limit order rests with that size; an immediate-or-cancel order cancels it.

A Match step is bounded by the maker's own unfilled size, and a reduce-only maker is bounded a second time by its current position: the fill can shrink that position toward zero but can never grow or flip it. A reduce-only quote's advertised size is therefore an upper bound, not the size on offer. A market whose status is ReduceOnly stamps every resting maker order reduce-only at fill time, whatever flag it was placed with, so the same bound applies to all of them.

A post-only order takes a different path entirely. determine_perp_fulfillment_methods_for_maker asks only whether the AMM's quote crosses the post-only price, and returns either a single uncapped AMM step or an empty plan. A post-only order is never matched against another maker, which is why two post-only orders can never trade with each other.

So the AMM does not sit at the end of a fixed JIT then DLOB then AMM waterfall, and it does not get a blanket priority over makers either. It competes level by level: better price fills first, and the AMM's fill is always capped at the price of the maker it jumped ahead of, so a maker is never crossed at a worse price than it quoted. JIT is not an earlier stage that runs before this walk. JIT maker quotes, and an AMM-JIT quote when the gates permit, compete for each fill inside the same walk, purely on price.

The AMM only participates if it passes the gates checked at fill time: AmmFill isn't paused for the market (PerpOperation.AMM_FILL), the AMM's drawdown is inside the limit the program checks on each fill, and the oracle (including the MM oracle, when active) is valid and not too volatile. "Not too volatile" for the MM oracle is a hard 1% band: once an active, sufficiently recent MM oracle price differs from the exchange oracle by more than MM_EXCHANGE_FALLBACK_THRESHOLD, one percent of price, the AMM is dropped from the plan. amm_can_fill_order resolves all of that into amm_is_available, and if it comes out false the plan contains Match steps only.

The AMM also still competes as a JIT maker inside a Match step. When amm_jit_allowed is true, the step builds an AmmJitQuoter alongside the maker's own order and the two split that fill. A fill the AMM wins that way is recorded with OrderActionExplanation.OrderFilledWithAMMJit, a variant on the live fill path rather than a legacy one kept for historical records.

A different model, in which every liquidity source quotes a price ladder and a router pass divides the taker's size across those ladders by priority tier, is designed but not live. Nothing on this page describes it. Anything describing quoter registration, ladder quoting, or per-source priority tiers belongs to that future design: see PropAMM and CLOB Order Flow, which carries its own not-live banner. Do not build against it yet.

Spot markets have no orderbook matching at all: place_spot_order / place_and_make_spot_order / fill_spot_order don't exist onchain, and there's no external-DEX (Serum/Phoenix/OpenBook) fulfillment path either. Both were removed. Spot markets exist only for collateral and borrow-lend; everything above applies to perp markets only.

Two AMM staleness thresholds, not one

Oracle staleness gates the AMM twice, at two different ages, and the two fail independently. OracleValidity::StaleForAMM is not a unit variant: it is StaleForAMM { immediate: bool, low_risk: bool }, and each flag is one of the thresholds.

  • low_risk compares the price's age against State.oracleGuardRails.validity.slotsBeforeStaleForAmm; read the live State account for that threshold. Failing it drops the AMM out of the plan entirely, leaving Match steps only.
  • immediate is far tighter. With no per-market override it is same-slot freshness for an exchange-sourced price, and 800ms (MM_ORACLE_MIN_WRITE_GAP, the shortest interval the MM oracle crank may write at) for an MM-sourced one. Failing it blocks only the immediate JIT path, and a low-risk fill still routes to the AMM.

The SDK flattens the pair into the OracleValidity numeric discriminant that AmmCache stores per market: 5 is stale on both thresholds, 6 is stale for immediate fills only, 7 is valid. The TypeScript enum spells those two members OracleValidity.StaleForAMMLowRisk and OracleValidity.isStaleForAmmImmediate. The second is camelCased differently from every other member of that enum, so copy it verbatim rather than guessing at it. A Rust caller has to destructure the variant: matching StaleForAMM as a unit variant does not compile.

A plan in practice

Take a 10 SOL market buy against a book holding 3 SOL at oracle + 0.05% and 3 SOL at oracle + 0.12%, with the AMM asking oracle + 0.02%. All prices here are illustrative. That plans as:

  • AMM capped at oracle + 0.05%, inserted ahead of the first maker because the AMM out-prices it
  • Match the 3 SOL maker at oracle + 0.05%
  • Match the 3 SOL maker at oracle + 0.12%, which the AMM no longer beats once its price has been pulled up
  • residual AMM for whatever size is still left inside the taker's limit

The taker gets a blended price better than any single source would provide, and the AMM appears at two separate points in one plan. Read the same shape from the maker's side: an order resting at oracle + 0.05% against an AMM quoting oracle + 0.02% is not touched until the AMM has taken the first slice at that maker's price. Being on the book is not enough. A maker has to beat the AMM's quote to see the whole fill.

Committed and indicative liquidity

A DLOB query returns two kinds of depth, and only one of them is a commitment.

Committed (DLOB) liquidity is real onchain resting limit orders. They are available for matching at their stated price until they are filled or cancelled.

Indicative liquidity is not a firm order. It covers the AMM's projected depth at each price level, which is what the vAMM curve would fill at rather than an order it has placed, and it covers the offchain indicative quotes that market makers publish to signal intent without committing onchain.

The distinction matters when reading depth. Including indicative liquidity gives a fuller picture of what a taker can probably get, but the AMM's price and available depth shift with every oracle update, and an indicative quote carries no obligation. In the L2 API response, the sources field on each price level separates the two. See REST API (L2/L3).

Market status

Every perp and spot market has a MarketStatus that gates whether it's fillable at all: Initialized (0), Active (1), ReduceOnly (2), Settlement (3), Delisted (4). Only Active markets accept new risk-increasing orders; ReduceOnly markets accept only orders that shrink an existing position; Settlement and Delisted markets are winding down and shouldn't appear as tradable in a market maker's UI or bot config at all.

A custom (non-IDL) decoder for PerpMarket/SpotMarket needs care here. These discriminant values are Velocity-specific: the deprecated FundingPaused/AmmPaused/FillPaused/WithdrawPaused variants that used to sit between Active and ReduceOnly were removed, so ReduceOnly/Settlement/Delisted now sit at 2/3/4 instead of 6/7/8. Decoding via the SDK's MarketStatus class (MarketStatus.ACTIVE, and so on) or the IDL avoids this entirely: only a decoder that hardcodes the old numeric values would misread the status.

Tick size

Every perp and spot market has an orderTickSize (PerpMarketAccount.orderTickSize / SpotMarketAccount.orderTickSize, PRICE_PRECISION units). The onchain program standardizes every auction price and oracle-offset limit price to this tick size before comparing it against anything else: long orders floor to the nearest tick, short orders ceil.

Prices computed client-side, rather than left for the program to clamp, should carry the market's tick size through so they match what the program will actually use:

  • getAuctionPrice(order, slot, oraclePrice, tickSize), see JIT Auctions
  • getLimitPrice(order, oraclePriceData, slot, fallbackPrice, tickSize)
  • DLOBNode.getPrice(oraclePriceData, slot, tickSize)

Every one of those tickSize parameters is optional and defaults to ONE, meaning no effective rounding, for backward compatibility. On any market with orderTickSize > 1, omitting it lets a client-computed price disagree with the program's by up to one tick, which is enough to misjudge whether a maker price is inside or outside the current auction range. Pass perpMarket.orderTickSize (or spotMarket.orderTickSize) explicitly.

Accessing the DLOB

Three read paths, in increasing order of control and effort: REST snapshots from the hosted server, a WebSocket stream from the same server, or a book built in-process from onchain accounts.

Endpoints are provisional. dlob.velocity.exchange below follows Velocity's <sub>.velocity.exchange hosted-endpoint convention but hasn't been confirmed as the final production hostname. Check with the team before hardcoding it.

REST API (L2/L3)

The hosted DLOB server provides L2 (aggregated price levels) and L3 (individual orders) endpoints. This is the simplest way to get an orderbook snapshot.

GET https://dlob.velocity.exchange/l2?marketName=SOL-PERP&depth=10&includeIndicative=true
GET https://dlob.velocity.exchange/l3?marketName=SOL-PERP

marketName takes a symbol such as SOL-PERP, BTC-PERP, or SOL for spot. depth is the number of price levels per side, defaulting to 100 and capped at 100. includeIndicative=true folds in the offchain indicative quotes; omitting it underestimates available liquidity. /l3 returns the individual orders with maker addresses, which is what identifies a specific resting order.

/l2 serves the server's Redis-cached book. Whether vAMM liquidity is included in that cache is decided server-side, not by a query parameter: includeVamm (along with includeOracle) is a parameter of the separate /batchL2 and /batchL2Cache endpoints, not of /l2. Use those to control vAMM inclusion explicitly.

An L2 level looks like this:

{
  "bids": [
    {
      "price": "99500000",
      "size": "15000000",
      "sources": {
        "dlob": "5000000",
        "vamm": "10000000"
      }
    }
  ],
  "asks": [
    {
      "price": "100500000",
      "size": "12000000",
      "sources": {
        "dlob": "8000000",
        "vamm": "4000000"
      }
    }
  ]
}

Two things trip up first integrations. sources is an object mapping source names to size strings, not a flat string: the common keys are "dlob" for resting limit orders and "vamm" for AMM indicative liquidity. And every price and size is raw precision, so divide prices by PRICE_PRECISION (1e6) and sizes by BASE_PRECISION (1e9) or the numbers are nonsense.

interface L2Level {
  price: string;
  size: string;
  sources: Record<string, string>; // e.g. { dlob: "5000000", vamm: "10000000" }
}

interface L2Response {
  bids: L2Level[];
  asks: L2Level[];
}

const response = await fetch(
  "https://dlob.velocity.exchange/l2?marketName=SOL-PERP&depth=10&includeIndicative=true"
);
const orderbook: L2Response = await response.json();

for (const bid of orderbook.bids) {
  const price = Number(bid.price) / 1e6; // PRICE_PRECISION = 1e6
  const size = Number(bid.size) / 1e9;   // BASE_PRECISION = 1e9
  const dlobSize = bid.sources.dlob ? Number(bid.sources.dlob) / 1e9 : 0;
  const vammSize = bid.sources.vamm ? Number(bid.sources.vamm) / 1e9 : 0;
  console.log(`Bid $${price.toFixed(2)}: ${size.toFixed(4)} (dlob: ${dlobSize.toFixed(4)}, vamm: ${vammSize.toFixed(4)})`);
}

Ecosystem builders reading the same server for a UI or an indexer will want the full endpoint reference in Orderbook + DLOB websocket, which documents the batch, top-makers, priority-fee, and auction-params routes as well.

WebSocket stream

For live orderbook feeds with subsecond updates, subscribe over WebSocket instead of polling:

const ws = new WebSocket("wss://dlob.velocity.exchange/ws");
ws.send(JSON.stringify({
  type: "subscribe",
  channel: "orderbook",
  marketType: "perp",
  market: "SOL-PERP",
  grouping: 10
}));

ws.onmessage = (event) => {
  const update = JSON.parse(event.data);
  // Handle orderbook update
};

The hosted server typically updates within 1 to 2 seconds of an onchain change, and lags further under load. That is fine for a UI and too slow for a latency-sensitive strategy.

Local DLOB from onchain accounts

Building the book in-process subscribes directly to onchain UserAccount changes and gives the rawest feed available, ahead of anything the hosted server publishes. This is the path for a bot that competes on latency.

import {
  DLOBSubscriber,
  OrderSubscriber,
  SlotSubscriber,
  MarketType
} from "@velocity-exchange/sdk";

const slotSubscriber = new SlotSubscriber(connection);
await slotSubscriber.subscribe();

const orderSubscriber = new OrderSubscriber({
  velocityClient,
  subscriptionConfig: { type: "websocket" },
  fastDecode: true,
  decodeData: true,
});
await orderSubscriber.subscribe();

const dlobSubscriber = new DLOBSubscriber({
  velocityClient,
  dlobSource: orderSubscriber,
  slotSource: slotSubscriber,
  updateFrequency: 1000,
});
await dlobSubscriber.subscribe();

// getBestBid/getBestAsk live on the DLOB snapshot itself, not the subscriber.
// Pass the market's tick size so client-side rounding matches the onchain standardization.
const dlob = dlobSubscriber.getDLOB();
const slot = slotSubscriber.getSlot();
const perpMarket = velocityClient.getPerpMarketAccount(marketIndex);
const oracleData = velocityClient.getMMOracleDataForPerpMarket(marketIndex);

const bestBid = dlob.getBestBid(marketIndex, slot, MarketType.PERP, oracleData, perpMarket.orderTickSize);
const bestAsk = dlob.getBestAsk(marketIndex, slot, MarketType.PERP, oracleData, perpMarket.orderTickSize);
// Both are `BN | undefined`: undefined means there's no resting-limit bid/ask right now
// (this ignores AMM fallback liquidity; it's DLOB-only, by design).

Two related feeds sit alongside the book. AuctionSubscriber streams active JIT auctions, covered in JIT Auctions. isSignedMsgOrder(order) identifies SWIFT-origin orders when both the SWIFT and onchain feeds are subscribed, covered in SWIFT API.

Gotchas

  • The AMM can out-price a maker at that maker's own price: an AMM step inserted ahead of a Match is capped at the maker's quote, so the taker's first slice trades at that price against the AMM instead. Beating the AMM's bid or ask is the price of getting the fill.
  • A matching price does not queue behind whoever quoted it first: the program applies no time priority within a price level, so posting early buys nothing against another maker at the same price. Improving the price by one tick does.
  • /l2 doesn't take an includeVamm parameter: whether vAMM liquidity is folded into the /l2 response is decided server-side by the cache it reads from, not by a query flag. includeVamm only exists on /batchL2 and /batchL2Cache.
  • includeIndicative=true for the full picture: indicative quotes from market makers (see Indicative Quotes) are only included when this flag is set.
  • Prices are raw precision: L2 response prices are in PRICE_PRECISION (1e6) and sizes in BASE_PRECISION (1e9). Forgetting to divide produces nonsensical numbers.
  • L2 sources is an object, not a string: each price level's sources field maps source names to size strings. Don't try to parse it as a flat value.
  • DLOB server can lag: the hosted server typically updates within 1 to 2 seconds of onchain changes, and further under load. For latency-sensitive strategies, build the book locally with DLOBSubscriber and OrderSubscriber.
  • Oracle offset orders move without a transaction: they appear at their effective price (oracle + offset) in the DLOB, and that price updates whenever the oracle does. The orderbook shifts with no onchain activity at all.