DLOB (Decentralized Limit Order Book)
What is the DLOB?
The Decentralized Limit Order Book (DLOB) is Velocity’s on-chain representation of all resting limit orders across all users. Unlike a traditional centralized order book maintained by an exchange, the DLOB is constructed locally by reading on-chain user accounts and aggregating their open limit orders into a price-ordered book.
When a new order arrives, keepers and market makers query the DLOB to find matching resting orders. Velocity’s matching engine then executes fills between the incoming taker and the resting makers on the DLOB, or routes to the AMM as a fallback.
Velocity removed spot DLOB trading — place_spot_order, place_and_take_spot_order, place_and_make_spot_order, and fill_spot_order no longer exist on-chain (calls now fail with SpotDlobTradingDisabled). Spot markets still exist for collateral/borrow-lend and for swaps, but they can no longer be traded on an order book. In practice the DLOB only ever contains MarketType.PERP orders — the APIs below still accept a marketType parameter for API compatibility, but pass MarketType.SPOT and you’ll only ever see an empty book.
When you’d use the DLOB:
- Market makers: quote against the current best bid/ask and respond to order flow
- Keeper/filler bots: identify and fill matchable perp orders for fee rewards
- Orderbook UIs: display a live aggregated L2 or L3 view of a perp market
SDK Usage
The SDK provides several classes to subscribe to and query the DLOB.
OrderSubscriber
Subscribes to all open user orders in real-time via WebSocket or polling. This is the raw data feed that the DLOB is built from. You need this running before you can maintain a local DLOB.
import { OrderSubscriber } from "@velocity-exchange/sdk";
const orderSubscriber = new OrderSubscriber({
velocityClient,
subscriptionConfig: { type: "websocket" },
fastDecode: true,
decodeData: true,
});
await orderSubscriber.subscribe();Class OrderSubscriberReference ↗
Class OrderSubscriberReference ↗OrderSubscriber — maintains a live, in-memory map of every `User` account (and therefore every open order) on the program, refreshed via polling, websocket program-account subscription, or gRPC/Laserstream, depending on `config.subscriptionConfig.type`. Used by keeper bots and `DLOBSubscriber` to build a full DLOB without individually subscribing to each trader's `User` account. Emits `orderCreated`/`userUpdated`/`updateReceived` on `eventEmitter` as updates arrive; see `OrderSubscriberEvents`.
| Property | Type | Required |
|---|---|---|
velocityClient | VelocityClient | Yes |
usersAccounts | Map<string, { slot: number; userAccount: UserAccount; }> | Yes |
subscription | PollingSubscription | WebsocketSubscription | grpcSubscription | Yes |
commitment | Commitment | Yes |
eventEmitter | StrictEventEmitter<EventEmitter, OrderSubscriberEvents> | Yes |
fetchPromise | Promise<void> | No |
fetchPromiseResolver | any | Yes |
mostRecentSlot | number | Yes |
decodeFn | (name: string, data: Buffer) => UserAccount | Yes |
decodeData | boolean | No |
fetchAllNonIdleUsers | boolean | No |
subscribe | () => Promise<void>Starts the underlying transport (polling interval, websocket program-account subscription, or gRPC stream) chosen at construction time. | Yes |
fetch | () => Promise<void>One-shot full refresh: fetches every `User` account matching the
configured filters via `getProgramAccounts` and feeds each through
`tryUpdateUserAccount`. Concurrent calls share the in-flight promise
rather than issuing a second `getProgramAccounts` request. Errors are
caught and logged, not thrown — the promise still resolves. | Yes |
tryUpdateUserAccount | (key: string, dataType: "raw" | "decoded" | "buffer", data: UserAccount | Buffer | string[], slot: number) => voidApplies an incoming update for the `User` account `key`, called by
`fetch`, `addPubkey`, and each subscription transport
(`PollingSubscription`/`WebsocketSubscription`/`grpcSubscription`).
Advances `mostRecentSlot` and always emits `updateReceived`. The stored
account is only replaced if `slot` is >= the currently cached slot for
that key; for `'raw'`/`'buffer'` inputs, before decoding it also cheaply
peeks the account's `lastActiveSlot` field at a fixed byte offset and
discards the update if that's older than what's cached, without paying
the cost of a full decode. On acceptance, emits `userUpdated`, and
`orderCreated` for any orders in the account whose `order.slot` falls in
`(previousSlot, slot]`. | Yes |
createDLOB | () => DLOBCreates a new DLOB for the order subscriber to fill. This will allow a
caller to extend the DLOB Subscriber with a custom DLOB type. | Yes |
getDLOB | (slot: number) => Promise<DLOB>Builds a fresh `DLOB` from the currently cached `User` accounts. For
reduce-only orders, resolves the base asset amount against the trader's
existing perp position (via `calculateOrderBaseAssetAmount`, `BASE_PRECISION`,
1e9) rather than trusting the order's own `baseAssetAmount` field, since a
reduce-only order's fillable size is capped by the position it reduces. | Yes |
getSlot | () => number | Yes |
addPubkey | (userAccountPublicKey: PublicKey) => Promise<void>Fetches a single `User` account directly (bypassing the subscription's filters) and applies it via `tryUpdateUserAccount`. Useful to backfill an account this subscriber's filters would otherwise exclude (e.g. an idle user with no orders). No-ops if the account doesn't exist. | Yes |
mustGetUserAccount | (key: string) => Promise<UserAccount>Returns the cached `UserAccount` for `key`, fetching it on demand via
`addPubkey` if not already cached. | Yes |
unsubscribe | () => Promise<void>Stops the underlying transport and clears the entire cached `User` account map. | Yes |
DLOBSubscriber
Builds and continuously maintains an aggregated orderbook from the order stream. Use this when you need a live L2/L3 view without manually managing the DLOB state.
import { DLOBSubscriber } from "@velocity-exchange/sdk";
const dlobSubscriber = new DLOBSubscriber({
velocityClient,
dlobSource: orderSubscriber, // feeds from your OrderSubscriber
slotSource: slotSubscriber, // needed for order expiry/timing
updateFrequency: 1000, // rebuild the book every 1000ms
});
await dlobSubscriber.subscribe();Class DLOBSubscriberReference ↗
Class DLOBSubscriberReference ↗Keeps a `DLOB` snapshot fresh on a timer and exposes convenience `getL2`/`getL3` accessors that resolve market name/index/type and oracle price data via `velocityClient` so callers don't have to. Until `subscribe()` resolves at least once, `getDLOB()`/`getL2`/`getL3` operate on an empty `DLOB`.
| Property | Type | Required |
|---|---|---|
velocityClient | VelocityClient | Yes |
dlobSource | DLOBSource | Yes |
slotSource | SlotSource | Yes |
updateFrequency | numberPolling interval in milliseconds between `DLOB` refreshes. | Yes |
intervalId | TimeoutHandle of the active polling timer, or `undefined` before `subscribe()`/after `unsubscribe()`. | No |
dlob | DLOBThe current `DLOB` snapshot; replaced wholesale on each refresh rather than mutated. | Yes |
eventEmitter | StrictEventEmitter<EventEmitter, DLOBSubscriberEvents>Emits `'update'` after each successful refresh and `'error'` if a refresh throws. | Yes |
subscribe | () => Promise<void>Fetches an initial `DLOB` snapshot (awaited before returning) and then starts a timer that
refreshes it every `updateFrequency` ms, emitting `'update'` on success or `'error'` if the
fetch throws. No-ops if already subscribed. | Yes |
updateDLOB | () => Promise<void>Fetches a new `DLOB` snapshot at the current slot (from `slotSource`) via `dlobSource.getDLOB` and replaces `this.dlob`. | Yes |
getDLOB | () => DLOB | Yes |
getL2 | ({ marketName, marketIndex, marketType, depth, includeVamm, numVammOrders, fallbackL2Generators, latestSlot, }: { marketName?: string; marketIndex?: number; marketType?: MarketType; depth?: number; includeVamm?: boolean; numVammOrders?: number; fallbackL2Generators?: L2OrderBookGenerator[]; latestSlot?: any; }) => L...Get the L2 (aggregated price/size) order book for a given market, using the current
`DLOB` snapshot and the current slot from `slotSource`. | Yes |
getL3 | ({ marketName, marketIndex, marketType, }: { marketName?: string; marketIndex?: number; marketType?: MarketType; }) => L3OrderBookGet the L3 (individual resting order) book for a given market, using the current `DLOB`
snapshot and the current slot from `slotSource`. Does not include fallback (e.g. vAMM)
liquidity. | Yes |
unsubscribe | () => Promise<void>Stops the periodic refresh timer, if running. Safe to call when not subscribed. | Yes |
SlotSubscriber
Tracks the current Solana slot. Required for timing-sensitive operations like JIT auction windows and order expiry checks.
import { SlotSubscriber } from "@velocity-exchange/sdk";
const slotSubscriber = new SlotSubscriber(connection);
await slotSubscriber.subscribe();
const currentSlot = slotSubscriber.getSlot();Class SlotSubscriberReference ↗
Class SlotSubscriberReference ↗SlotSubscriber — tracks the current slot via `connection.onSlotChange`, with an optional stall-detection resubscribe. Slot updates that are not strictly greater than the currently tracked slot are ignored (protects against out-of-order delivery).
| Property | Type | Required |
|---|---|---|
connection | any | Yes |
currentSlot | number | Yes |
subscriptionId | number | No |
eventEmitter | StrictEventEmitter<EventEmitter, SlotSubscriberEvents> | Yes |
timeoutId | Timeout | No |
resubTimeoutMs | number | No |
isUnsubscribing | boolean | Yes |
receivingData | boolean | Yes |
subscribe | () => Promise<void>Fetches the current slot once via RPC, then subscribes to `onSlotChange` for live updates. Idempotent while already subscribed. | Yes |
updateCurrentSlot | any | Yes |
setTimeout | any | Yes |
getSlot | () => number | Yes |
unsubscribe | (onResub?: boolean | undefined) => Promise<void>Removes the `onSlotChange` listener and cancels the resub timeout. | Yes |
DLOB
The core data structure with bid/ask sides and query methods. Under normal usage you access this via dlobSubscriber.getDLOB() rather than instantiating it directly.
import { DLOB } from "@velocity-exchange/sdk";
// Access via DLOBSubscriber (recommended)
const dlob = dlobSubscriber.getDLOB();Class DLOBReference ↗
Class DLOBReference ↗In-memory order book. Indexes every open order it is given into per-market, per-side sorted `NodeList`s (see `MarketNodeLists`), and provides the crossing/fill-finding logic (`findNodesToFill`) and aggregated book views (`getL2`/`getL3`) that keepers and clients use to predict and drive on-chain fills. A `DLOB` instance is normally built once per slot (e.g. via `initFromUserMap`) rather than mutated indefinitely, since state changes (`insertOrder`, `delete`) must be paired with the caller's own bookkeeping of what's already been applied.
| Property | Type | Required |
|---|---|---|
openOrders | Map<MarketTypeStr, Set<string>>Order signatures (`getOrderSignature`) currently open, keyed by market type (`'perp'`/`'spot'`). | Yes |
orderLists | Map<MarketTypeStr, Map<number, MarketNodeLists>>Every market's `MarketNodeLists`, keyed by market type then market index. | Yes |
maxSlotForRestingLimitOrders | numberThe highest slot `updateRestingLimitOrders` has processed; used to skip redundant re-promotion of taking→resting orders when called with a slot that's already been seen. | Yes |
initialized | booleanSet to `true` once `initFromUserMap` has successfully populated this instance; `initFromUserMap` is then a no-op. | Yes |
init | any | Yes |
getOpenOrdersForMarketType | any | Yes |
getOrderListsForMarketType | any | Yes |
tryGetMarketNodeLists | any | Yes |
getMarketNodeLists | any | Yes |
clear | () => voidEmpties every order list and resets the DLOB to its freshly-constructed (uninitialized) state, including `maxSlotForRestingLimitOrders` and `initialized`. | Yes |
initFromUserMap | (userMap: UserMap, slot: number) => Promise<boolean>Populates this DLOB from every open order across every user in `userMap`. For reduce-only
orders, the fillable amount is capped via `calculateOrderBaseAssetAmount` against the
user's existing perp position for that market, rather than trusting the order's full
stated `baseAssetAmount`. No-ops (returns `false` immediately) if this instance has already
been initialized — call `clear()` first to rebuild from scratch. | Yes |
insertOrder | (order: Order, userAccount: string, slot: number, baseAssetAmount: BN, onInsert?: OrderBookCallback | undefined) => voidInserts a single on-chain order into the appropriate `NodeList` for its market/side/type.
No-ops if the order's status isn't `open`, or if its `orderType` isn't one of the
DLOB-supported types (`market`, `limit`, `triggerMarket`, `triggerLimit`, `oracle`).
Lazily creates the market's `MarketNodeLists` (via `addOrderList`) on first insert for that
market. Which list the order lands in (taking vs. resting limit, floating, market, or
inactive trigger) is decided by `getListForOnChainOrder`. | Yes |
insertSignedMsgOrder | (order: Order, userAccount: string, baseAssetAmount?: any, onInsert?: OrderBookCallback | undefined) => voidInserts an off-chain signed-message order (not yet landed on-chain) into the market's
`signedMsg` bid/ask list, unconditionally (no status/order-type filtering, unlike
`insertOrder`). Lazily creates the market's `MarketNodeLists` on first insert. | Yes |
addOrderList | (marketType: MarketTypeStr, marketIndex: number) => voidCreates and registers an empty `MarketNodeLists` (all six order categories, both sides) for `marketIndex`, overwriting any existing lists for that market. | Yes |
delete | (order: Order, userAccount: PublicKey, slot: number, onDelete?: OrderBookCallback | undefined) => voidRemoves an order from whichever `NodeList` it currently lives in. No-ops if the order's
status isn't `open`. First calls `updateRestingLimitOrders(slot)` so a taking-limit order
that has since become a resting-limit order is looked up (and removed from) the correct
list. | Yes |
getListForOnChainOrder | (order: Order, slot: number) => NodeList<any> | undefinedDetermines which `NodeList` an order belongs in, given its current state and the slot:
a trigger order (`triggerMarket`/`triggerLimit`) that hasn't fired yet goes in
`trigger.above`/`trigger.below`; a market/oracle-type order goes in `market`; a limit order
with a non-zero `oraclePriceOffset` goes in `floatingLimit`; otherwise a limit order goes in
`restingLimit` once its auction is complete or it's post-only (per `isRestingLimitOrder`),
and in `takingLimit` while still auctioning. | Yes |
getListForOnChainOrderOrThrow | any | Yes |
updateRestingLimitOrders | (slot: number) => voidPromotes any `takingLimit` orders across all perp and spot markets whose auction has since
completed (per `isRestingLimitOrder`) into their market's `restingLimit` list. No-ops if
`slot` is not newer than the last slot this was called with (`maxSlotForRestingLimitOrders`),
so it is cheap to call defensively before any read that depends on resting-limit state
being current (most getters here do so internally). | Yes |
updateRestingLimitOrdersForMarketType | (slot: number, marketTypeStr: MarketTypeStr) => voidDoes the `takingLimit` → `restingLimit` promotion (see `updateRestingLimitOrders`) for every market of one market type. | Yes |
getOrder | (orderId: number, userAccount: PublicKey) => Order | undefinedLooks up an order by id/owner across every `NodeList` in the DLOB (perp and spot, all
categories/sides) via `getNodeLists`. O(number of lists); prefer a narrower lookup (e.g.
`NodeList.get`) if you already know the order's market/type. | Yes |
findNodesToFill | <T extends MarketType>(marketIndex: number, fallbackBid: any, fallbackAsk: any, slot: number, ts: number, marketType: T, oraclePriceData: T extends { spot: unknown; } ? OraclePriceData : MMOraclePriceData, stateAccount: StateAccount, marketAccount: T extends { ...; } ? SpotMarketAccount : PerpMarketAccount) => NodeT...Top-level entry point for keepers: finds every node in one market that is currently
fillable, combining four sources — crossing resting-limit orders
(`findRestingLimitOrderNodesToFill`), taking (still-auctioning) orders that cross a maker or
fallback price (`findTakingNodesToFill`), expired orders to cancel/settle
(`findExpiredNodesToFill`), and unfillable reduce-only orders below the step size to cancel
(`findUnfillableReduceOnlyOrdersToCancel`). Returns `[]` immediately if fills are paused for
this market (`fillPaused`). The market's `orderTickSize` is read from `marketAccount` and
threaded through to every price comparison below so all crossing checks agree with on-chain
price standardization. | Yes |
getMakerRebate | (marketType: MarketType, stateAccount: StateAccount, marketAccount: SpotMarketAccount | PerpMarketAccount) => { ...; }Reads the tier-0 maker rebate fraction (`makerRebateNumerator / makerRebateDenominator`)
for a market from `stateAccount`'s perp/spot fee structure, then scales the numerator up by
the market's `feeAdjustment` percentage if one is set. Used by `findRestingLimitOrderNodesToFill`
to size the buffer added to fallback prices so fallback fills aren't triggered by rebate-sized
noise. | Yes |
mergeNodesToFill | (restingLimitOrderNodesToFill: NodeToFill[], takingOrderNodesToFill: NodeToFill[]) => NodeToFill[]Merges two `NodeToFill` arrays (typically resting-limit crossings and taking-order
crossings for the same market/pass) by taker order signature, concatenating `makerNodes`
for any taker that appears in both — e.g. an order that both crosses a resting maker and
separately crosses fallback liquidity ends up as one `NodeToFill` with both maker sources. | Yes |
findRestingLimitOrderNodesToFill | <T extends MarketType>(marketIndex: number, slot: number, marketType: T, oraclePriceData: T extends { spot: unknown; } ? OraclePriceData : MMOraclePriceData, isAmmPaused: boolean, stateAccount: StateAccount, marketAccount: T extends { ...; } ? SpotMarketAccount : PerpMarketAccount, makerRebateNumerator: number, make...Finds resting-limit-order fills for a market: resting bids/asks that cross each other
(`findCrossingRestingLimitOrders`), plus resting asks that cross the fallback bid and
resting bids that cross the fallback ask (each skipped entirely if the AMM is paused).
The fallback price on each side is tightened by the maker rebate before comparing, so a
maker order priced exactly at the rebate-adjusted fallback isn't spuriously flagged as
crossing (`fallbackBidWithBuffer = fallbackBid - fallbackBid * makerRebateNumerator / makerRebateDenominator`,
and symmetrically for the ask). | Yes |
findTakingNodesToFill | <T extends MarketType>(marketIndex: number, slot: number, marketType: T, oraclePriceData: T extends { spot: unknown; } ? OraclePriceData : MMOraclePriceData, isAmmPaused: boolean, state: StateAccount, marketAccount: T extends { ...; } ? SpotMarketAccount : PerpMarketAccount, fallbackAsk: any, fallbackBid?: any, tick...Finds fills for taking (still-auctioning) orders: taking asks crossing resting bids or the
fallback bid, and taking bids crossing resting asks or the fallback ask
(`findTakingNodesCrossingMakerNodes` / `findNodesCrossingFallbackLiquidity`). Fallback
crossing checks are skipped entirely when `isAmmPaused`. For spot markets, a taking order is
only allowed to cross the opposite fallback price if doing so wouldn't also require crossing
beyond the *other* fallback price (see the inline `fallbackBid`/`fallbackAsk` guards) —
this prevents a taking order from routing through DLOB makers priced worse than the AMM. | Yes |
findTakingNodesCrossingMakerNodes | <T extends MarketType>(marketIndex: number, slot: number, marketType: T, oraclePriceData: T extends { spot: unknown; } ? OraclePriceData : MMOraclePriceData, takerNodeGenerator: Generator<...>, makerNodeGeneratorFn: (marketIndex: number, slot: number, marketType: T, oraclePriceData: T extends { ...; } ? OraclePriceD...Walks `takerNodeGenerator` (taking bids or asks, sorted by arrival slot) against a fresh
maker-side generator (built per taker via `makerNodeGeneratorFn`, e.g.
`getRestingLimitBids`) and records a `NodeToFill` for every taker/maker pair that
`doesCross` accepts, skipping same-user matches. For each match this method also **mutates
DLOB state**: it applies the simulated fill to both the maker's and taker's order lists
(via `NodeList.update`) so subsequent iterations see updated `baseAssetAmountFilled` and a
taker stops matching once fully filled. Because maker nodes (sorted by price) are scanned
in order, `doesCross` returning false breaks out of the maker loop entirely — this is
correct for resting-limit makers but relies on the maker generator being price-sorted, not
time-sorted. | Yes |
findNodesCrossingFallbackLiquidity | <T extends MarketType>(marketType: T, slot: number, oraclePriceData: T extends { spot: unknown; } ? OraclePriceData : MMOraclePriceData, nodeGenerator: Generator<DLOBNode, any, any>, doesCross: (nodePrice: any) => boolean, state: StateAccount, marketAccount: T extends { ...; } ? SpotMarketAccount : PerpMarketAccount...Scans `nodeGenerator` for nodes that both cross the fallback price (`doesCross`, evaluated
against each node's `getLimitPrice`, or crossing unconditionally if the node has no limit
price) and have fallback liquidity actually available to fill against. For spot markets,
post-only orders are skipped (they can never take against the AMM) and fallback liquidity
is always considered available; for perp markets, availability additionally requires
`isFallbackAvailableLiquiditySource` (broadly: the order's auction is complete and the
oracle is valid enough for AMM fills). Does not mutate any order state — unlike
`findTakingNodesCrossingMakerNodes`, fallback fills are expected to be sized/settled
on-chain rather than simulated here. | Yes |
findExpiredNodesToFill | (marketIndex: number, ts: number, marketType: MarketType, slot?: any) => NodeToFill[]Finds orders in a market that are eligible to be expired: any non-trigger, non-TIF-limit
order whose `maxTs` (plus a 25-second buffer for limit orders, via `isOrderExpired`) has
passed the given timestamp. Also proactively removes (not just reports) signed-message
orders whose auction window (`order.slot + order.auctionDuration`) has passed `slot`, since
those never landed on-chain and have no on-chain expiration to wait for. | Yes |
findUnfillableReduceOnlyOrdersToCancel | (marketIndex: number, marketType: MarketType, stepSize: BN) => NodeToFill[]Finds reduce-only orders across every category/side in a market whose remaining
`baseAssetAmount` (as tracked on the node, not necessarily the order's original size) has
dropped below the market's minimum step size — meaning the order can never be filled again
and should be canceled by a keeper rather than left to linger. | Yes |
getTakingBids | <T extends MarketType>(marketIndex: number, marketType: T, slot: number, oraclePriceData: T extends { spot: unknown; } ? OraclePriceData : MMOraclePriceData, filterFcn?: DLOBFilterFcn | undefined) => Generator<...>Yields taking (still-auctioning) bid nodes for a market — market-bid orders, taking-limit
bids, and signed-message bids not yet resting — merged in arrival order (earliest `slot`
first, via `getBestNode`). Calls `updateRestingLimitOrders(slot)` first so a signed-message
order that has since become a resting-limit order is excluded here. | Yes |
getTakingAsks | <T extends MarketType>(marketIndex: number, marketType: T, slot: number, oraclePriceData: T extends { spot: unknown; } ? OraclePriceData : MMOraclePriceData, filterFcn?: DLOBFilterFcn | undefined) => Generator<...>Same as `getTakingBids`, but for the ask side. | Yes |
signedMsgGenerator | (signedMsgOrderList: NodeList<"signedMsg">, filter: (x: DLOBNode) => boolean) => Generator<DLOBNode, any, any>Filters a `signedMsg` `NodeList`'s nodes by an arbitrary predicate — used to split signed-message orders into "still taking" vs. "now resting" subsets based on `isRestingLimitOrder`. | Yes |
getBestNode | <T extends MarketTypeStr>(generatorList: Generator<DLOBNode, any, any>[], oraclePriceData: T extends "spot" ? OraclePriceData : MMOraclePriceData, slot: number, compareFcn: (bestDLOBNode: DLOBNode, currentDLOBNode: DLOBNode, slot: number, oraclePriceData: T extends "spot" ? OraclePriceData : MMOraclePriceData) => bo...K-way-merges multiple node generators (e.g. one per order category feeding one side of the
book) into a single generator ordered by `compareFcn`, skipping nodes that are already
fully filled (`isBaseFilled`) or rejected by `filterFcn`. This is the shared core behind
`getTakingBids`/`getTakingAsks`/`getRestingLimitBids`/`getRestingLimitAsks`/`getBids`/`getAsks`
— each just supplies a different `generatorList` and `compareFcn`. | Yes |
getRestingLimitAsks | <T extends MarketType>(marketIndex: number, slot: number, marketType: T, oraclePriceData: T extends { spot: unknown; } ? OraclePriceData : MMOraclePriceData, filterFcn?: DLOBFilterFcn | undefined, tickSize?: any) => Generator<...>Yields resting-limit ask nodes for a market — `restingLimit`, `floatingLimit`, and any
`signedMsg` asks that have become resting — merged best-price-first (lowest ask price
first, ties broken by `getBestNode`'s underlying comparator). Calls
`updateRestingLimitOrders(slot)` first. `tickSize` is threaded into every price comparison
via `DLOBNode.getPriceOrThrow`, so pass the market's `orderTickSize` to match on-chain
price standardization — omitting it defaults to no rounding (tick of 1). | Yes |
getRestingLimitBids | <T extends MarketType>(marketIndex: number, slot: number, marketType: T, oraclePriceData: T extends { spot: unknown; } ? OraclePriceData : MMOraclePriceData, filterFcn?: DLOBFilterFcn | undefined, tickSize?: any) => Generator<...>Same as `getRestingLimitAsks`, but for the bid side (merged best-price-first, highest bid first). | Yes |
getAsks | <T extends MarketType>(marketIndex: number, _fallbackAsk: any, slot: number, marketType: T, oraclePriceData: T extends { spot: unknown; } ? OraclePriceData : MMOraclePriceData, filterFcn?: DLOBFilterFcn | undefined, tickSize?: any) => Generator<...>Merges `getTakingAsks` and `getRestingLimitAsks` into a single best-price-first generator
(ties broken by earliest arrival slot). Nodes with no resolvable price (e.g. still
mid-auction) sort as price `0` — i.e. best — since `getPrice` (not `getPriceOrThrow`) is
used here. Unlike `findTakingNodesToFill`/`findNodesToFill`, this does **not** merge in
fallback (e.g. vAMM) liquidity; the `fallbackAsk` parameter is currently unused/reserved. | Yes |
getBids | <T extends MarketType>(marketIndex: number, _fallbackBid: any, slot: number, marketType: T, oraclePriceData: T extends { spot: unknown; } ? OraclePriceData : MMOraclePriceData, filterFcn?: DLOBFilterFcn | undefined, tickSize?: any) => Generator<...>Merges `getTakingBids` and `getRestingLimitBids` into a single best-price-first generator
(ties broken by earliest arrival slot). Nodes with no resolvable price sort as `BN_MAX` —
i.e. worst — since a priceless bid shouldn't be preferred over a priced one. Does not merge
in fallback (e.g. vAMM) liquidity; the `fallbackBid` parameter is currently unused/reserved. | Yes |
findCrossingRestingLimitOrders | <T extends MarketType>(marketIndex: number, slot: number, marketType: T, oraclePriceData: T extends { spot: unknown; } ? OraclePriceData : MMOraclePriceData, tickSize?: any) => NodeToFill[]Finds pairs of resting-limit asks and bids that cross each other (`bidPrice >= askPrice`),
assigns maker/taker roles via `determineMakerAndTaker` (post-only orders are always makers;
otherwise whichever order's auction finished later is the taker), and simulates the fill by
updating both orders' `baseAssetAmountFilled` in their `NodeList`s so subsequent iterations
see the reduced remaining size. Same-user matches are skipped. Because both ask and bid
generators are price-sorted, the inner loop `break`s as soon as `bidPrice < askPrice` for a
given ask, since no later (worse) bid can cross either. | Yes |
determineMakerAndTaker | (askNode: DLOBNode, bidNode: DLOBNode) => { takerNode: DLOBNode; makerNode: DLOBNode; } | undefinedDecides which of a crossing ask/bid pair is the maker and which is the taker: if both are
post-only, they can't be matched (`undefined`); if exactly one is post-only, it's the
maker; otherwise whichever order's auction window (`order.slot + order.auctionDuration`)
ends later is treated as the taker (it "arrived crossing" the earlier order). | Yes |
getBestAsk | <T extends MarketType>(marketIndex: number, slot: number, marketType: T, oraclePriceData: T extends { spot: unknown; } ? OraclePriceData : MMOraclePriceData, tickSize?: any) => anyGets the best (lowest) resting-limit ask price for a market. Does not consider fallback
(e.g. vAMM) liquidity. | Yes |
getBestBid | <T extends MarketType>(marketIndex: number, slot: number, marketType: T, oraclePriceData: T extends { spot: unknown; } ? OraclePriceData : MMOraclePriceData, tickSize?: any) => anyGets the best (highest) resting-limit bid price for a market. Does not consider fallback
(e.g. vAMM) liquidity. | Yes |
getStopLosses | (marketIndex: number, marketType: MarketType, direction: PositionDirection) => Generator<DLOBNode, any, any>Yields untriggered trigger orders that would close a position in `direction`: for a `long`
position, short-direction orders in the `trigger.below` list (stop triggers on a price
drop); for a `short` position, long-direction orders in `trigger.above` (stop triggers on a
price rise). Includes both `triggerMarket` and `triggerLimit` order types — see
`getStopLossMarkets`/`getStopLossLimits` to filter to one. | Yes |
getStopLossMarkets | (marketIndex: number, marketType: MarketType, direction: PositionDirection) => Generator<DLOBNode, any, any>Same as `getStopLosses`, filtered to `triggerMarket` orders only. | Yes |
getStopLossLimits | (marketIndex: number, marketType: MarketType, direction: PositionDirection) => Generator<DLOBNode, any, any>Same as `getStopLosses`, filtered to `triggerLimit` orders only. | Yes |
getTakeProfits | (marketIndex: number, marketType: MarketType, direction: PositionDirection) => Generator<DLOBNode, any, any>Yields untriggered trigger orders that would close a position in `direction` for profit:
for a `long` position, short-direction orders in `trigger.above` (take-profit on a price
rise); for a `short` position, long-direction orders in `trigger.below` (take-profit on a
price drop). Includes both `triggerMarket` and `triggerLimit` order types — see
`getTakeProfitMarkets`/`getTakeProfitLimits` to filter to one. | Yes |
getTakeProfitMarkets | (marketIndex: number, marketType: MarketType, direction: PositionDirection) => Generator<DLOBNode, any, any>Same as `getTakeProfits`, filtered to `triggerMarket` orders only. | Yes |
getTakeProfitLimits | (marketIndex: number, marketType: MarketType, direction: PositionDirection) => Generator<DLOBNode, any, any>Same as `getTakeProfits`, filtered to `triggerLimit` orders only. | Yes |
findNodesToTrigger | (marketIndex: number, slot: number, triggerPrice: BN, marketType: MarketType, stateAccount: StateAccount) => NodeToTrigger[]Finds trigger orders whose condition is now satisfied by `triggerPrice`: `trigger.above`
orders with `triggerPrice > order.triggerPrice`, and `trigger.below` orders with
`triggerPrice < order.triggerPrice`. Both lists are sorted by trigger price with the
nearest-to-triggering order at `head`, so each scan walks from `head` and `break`s at the
first order that isn't (yet) triggered. Returns `[]` immediately if the exchange is paused. | Yes |
printTop | (velocityClient: VelocityClient, slotSubscriber: SlotSubscriber, marketIndex: number, marketType: MarketType) => voidDebug helper: logs the market's best bid, best ask, and mid price (all resting-limit only,
no fallback liquidity), along with each side's spread to the current oracle price, as a
percentage. | Yes |
getDLOBOrders | () => DLOBOrdersFlattens every order across every `NodeList` (perp and spot, all categories/sides) into a single `DLOBOrders` array of `{ user, order }` pairs, in no particular cross-list order. | Yes |
getNodeLists | () => Generator<NodeList<DLOBNodeType>, any, any>Yields every `NodeList` (all ten category/side combinations, per market) across every perp market, then every spot market. Used by `getOrder`/`getDLOBOrders` to walk the entire book. | Yes |
getL2 | <T extends MarketType>({ marketIndex, marketType, slot, oraclePriceData, depth, fallbackL2Generators, tickSize, }: { marketIndex: number; marketType: T; slot: number; oraclePriceData: T extends { spot: unknown; } ? OraclePriceData : MMOraclePriceData; depth: number; fallbackL2Generators?: L2OrderBookGenerator[]; tic...Get an L2 (aggregated price/size) view of the order book for a given market: resting-limit
DLOB liquidity merged with any supplied fallback generators (e.g. the vAMM, via
`getVammL2Generator`), then bucketed into up to `depth` levels per side via `createL2Levels`.
Does not include taking (still-auctioning) orders — only resting-limit makers and fallback
liquidity are represented. | Yes |
getL3 | <T extends MarketType>({ marketIndex, marketType, slot, oraclePriceData, tickSize, }: { marketIndex: number; marketType: T; slot: number; oraclePriceData: T extends { spot: unknown; } ? OraclePriceData : MMOraclePriceData; tickSize?: any; }) => L3OrderBookGet an L3 (individual resting order) view of the order book for a given market. Only
resting-limit orders are included — no taking orders and no fallback (e.g. vAMM) liquidity. | Yes |
estimateFillExactBaseAmountInForSide | any | Yes |
estimateFillWithExactBaseAmount | <T extends MarketType>({ marketIndex, marketType, baseAmount, orderDirection, slot, oraclePriceData, tickSize, }: { marketIndex: number; marketType: T; baseAmount: BN; orderDirection: PositionDirection; slot: number; oraclePriceData: T extends { spot: unknown; } ? OraclePriceData : MMOraclePriceData; tickSize?: any;...Estimates the quote amount that would be filled for a given base amount, walking
resting-limit asks (for a `long`/buy) or bids (for a `short`/sell) from best price outward
and summing `price * size` until `baseAmount` is consumed. Does not include fallback (e.g.
vAMM) liquidity or taking orders, and does not mutate any order state — this is a read-only
estimate, not a simulated fill. | Yes |
getBestMakers | <T extends MarketType>({ marketIndex, marketType, direction, slot, oraclePriceData, numMakers, tickSize, }: { marketIndex: number; marketType: T; direction: PositionDirection; slot: number; oraclePriceData: T extends { spot: unknown; } ? OraclePriceData : MMOraclePriceData; numMakers: number; tickSize?: any; }) => P...Collects the pubkeys of up to `numMakers` distinct makers currently resting best-priced on
one side of the book — bids for a `long` taker, asks for a `short` taker — in best-price
order. Used to pick candidate maker accounts to pass as `remaining_accounts` when
submitting a fill instruction. A maker with multiple resting orders at different prices
only counts once toward `numMakers`. | Yes |
UserMap
Efficiently tracks and caches the accounts of many users simultaneously. Used by liquidation bots and other applications that need to monitor positions across the whole protocol, rather than just orders.
import { UserMap } from "@velocity-exchange/sdk";Class UserMapReference ↗
Class UserMapReference ↗In-memory cache of every `User` account on the program, keyed by the `User` account's own public key.
Sync/subscription filtering (see `getFilters`) is built from memcmp filters: the base `getUserFilter()` (matches the `User` account discriminator — required, or every account on chain would match) is always present; `getNonIdleUserFilter()` is added unless `includeIdle` is set (idle accounts are excluded by default to reduce subscription volume); `getUsersWithPoolId(filterByPoolId)` is added when `filterByPoolId` is set; and any `additionalFilters` from the config are appended last. The same filter set is used for the initial `getProgramAccounts` sync and for the live websocket/gRPC subscription, so what you sync is what you keep getting updates for.
Automatically does a full `sync()` whenever the program's `StateAccount.numberOfSubAccounts` changes (new/deleted user accounts), unless `disableSyncOnTotalAccountsChange` is set.
| Property | Type | Required |
|---|---|---|
userMap | any | Yes |
velocityClient | VelocityClient | Yes |
eventEmitter | StrictEventEmitter<EventEmitter, UserEvents> | Yes |
connection | any | Yes |
commitment | any | Yes |
includeIdle | any | Yes |
filterByPoolId | any | No |
additionalFilters | any | No |
disableSyncOnTotalAccountsChange | any | Yes |
lastNumberOfSubAccounts | any | No |
subscription | any | Yes |
stateAccountUpdateCallback | any | Yes |
decode | any | Yes |
mostRecentSlot | any | Yes |
syncConfig | any | Yes |
syncPromise | any | No |
syncPromiseResolver | any | Yes |
throwOnFailedSync | any | Yes |
subscribe | () => Promise<void>Populates the map with a full initial `sync()` (no-op if already
populated) and starts the configured live subscription
(`'websocket'`/`'polling'`/`'grpc'`), plus (unless
`disableSyncOnTotalAccountsChange`) a listener that triggers a full
re-sync whenever `StateAccount.numberOfSubAccounts` changes. | Yes |
addPubkey | (userAccountPublicKey: PublicKey, userAccount?: UserAccount | undefined, slot?: number | undefined, accountSubscription?: UserSubscriptionConfig | undefined) => Promise<...>Adds `userAccountPublicKey` to the map, creating a `User` for it.
By default (`accountSubscription` omitted), subscribes it with a
`OneShotUserAccountSubscriber` seeded from `userAccount`/`slot` rather
than a live per-account websocket subscription — the map already gets
live updates in bulk via its own subscription (`WebsocketSubscription`/
`PollingSubscription`/`grpcSubscription`), so per-`User` subscriptions
here would needlessly multiply RPC load. | Yes |
has | (key: string) => booleanReturns true if a `User` account keyed by `key` (the `User` account pubkey, base58) is cached in the map. | Yes |
get | (key: string) => User | undefinedgets the User for a particular userAccountPublicKey, if no User exists, undefined is returned | Yes |
getWithSlot | (key: string) => DataAndSlot<User> | undefinedLike `get`, but also returns the slot at which the `User` account was last observed. | Yes |
mustGet | (key: string, accountSubscription?: UserSubscriptionConfig | undefined) => Promise<User>gets the User for a particular userAccountPublicKey, if no User exists, new one is created | Yes |
mustGetWithSlot | (key: string, accountSubscription?: UserSubscriptionConfig | undefined) => Promise<DataAndSlot<User>>Like `mustGet`, but also returns the slot at which the `User` account was observed. | Yes |
mustGetUserAccount | (key: string) => Promise<UserAccount>Like `mustGet`, but returns the underlying `UserAccount` data directly (throws if the `User`'s account is not loaded). | Yes |
getUserAuthority | (key: string) => PublicKey | undefinedgets the Authority for a particular userAccountPublicKey, if no User exists, undefined is returned | Yes |
getDLOB | (slot: number) => Promise<DLOB>Implements the `DLOBSource` interface: builds a `DLOB` from every
subscribed user's open orders. | Yes |
updateWithOrderRecord | (record: OrderRecord) => Promise<void>Ensures an entry exists in the map for `record.user`, adding it via `addPubkey` if not already present. | Yes |
updateWithEventRecord | (record: any) => Promise<void>Incrementally updates the map in response to a single program event,
ensuring an entry exists for every `User` account the event references
(deposit/funding/liquidation/order/order-action/settle-pnl/new-user
records). Unrecognized event types are silently ignored. | Yes |
values | () => IterableIterator<User>Iterates all cached `User` instances. | Yes |
valuesWithSlot | () => IterableIterator<DataAndSlot<User>>Like `values`, but paired with the slot each `User` was last observed at. | Yes |
entries | () => IterableIterator<[string, User]>Iterates all `[userAccountPublicKey, User]` pairs in the map. | Yes |
entriesWithSlot | () => IterableIterator<[string, DataAndSlot<User>]>Like `entries`, but paired with the slot each `User` was last observed at. | Yes |
size | () => numberNumber of `User` accounts currently cached in the map. | Yes |
getUniqueAuthorities | (filterCriteria?: UserAccountFilterCriteria | undefined) => PublicKey[]Returns a unique list of authorities for all users in the UserMap that meet the filter criteria | Yes |
sync | () => Promise<void>Runs a full sync using the strategy configured in `UserMapConfig.syncConfig` (`'default'` or `'paginated'` — see `SyncConfig`). | Yes |
getFilters | anyBuilds the memcmp filter set for both the initial sync and the live
subscription: always the `User`-account discriminator filter; plus a
non-idle filter unless `includeIdle`; plus a pool-id filter if
`filterByPoolId` is set; plus any caller-supplied `additionalFilters`. | Yes |
defaultSync | anySyncs the UserMap using the default sync method (single getProgramAccounts call with filters).
This method may fail when velocity has too many users. (nodejs response size limits) | Yes |
paginatedSync | anySyncs the UserMap using the paginated sync method (multiple getMultipleAccounts calls with filters).
This method is more reliable when velocity has many users. | Yes |
unsubscribe | () => Promise<void>Tears down the live subscription, unsubscribes and removes every cached
`User`, and (if registered) removes the `stateAccountUpdate` listener
that triggers auto-resync on `numberOfSubAccounts` changes. | Yes |
updateUserAccount | (key: string, userAccount: UserAccount, slot: number) => Promise<void>Applies a fresh `userAccount` observation for `key` at `slot`. If the
user is already cached, updates in place only if `slot` is at least as
new as the cached slot (stale/out-of-order updates are dropped) and
emits `'userUpdate'`. If not cached yet, adds it via `addPubkey`.
Also advances `getSlot()`'s tracked most-recent slot. | Yes |
updateLatestSlot | (slot: number) => voidAdvances the map's tracked most-recent slot to `slot` if it's newer. | Yes |
getSlot | () => numberReturns the most recent slot at which any account update has been observed. | Yes |
Setting Up a Local DLOB
This is the full setup sequence to get a live, continuously-updated orderbook running:
import { SlotSubscriber, OrderSubscriber, DLOBSubscriber } from "@velocity-exchange/sdk";
// 1. Track the current slot (needed for order expiry)
const slotSubscriber = new SlotSubscriber(connection);
await slotSubscriber.subscribe();
// 2. Subscribe to all open orders across all users
const orderSubscriber = new OrderSubscriber({
velocityClient,
subscriptionConfig: { type: "websocket" },
fastDecode: true,
decodeData: true,
});
await orderSubscriber.subscribe();
// 3. Build and maintain the DLOB from the order stream
const dlobSubscriber = new DLOBSubscriber({
velocityClient,
dlobSource: orderSubscriber,
slotSource: slotSubscriber,
updateFrequency: 1000,
});
await dlobSubscriber.subscribe();Example DLOB setupReference ↗
Example DLOB setupReference ↗DLOB setup.Getting L2 Orderbook Data
Once subscribed, query the aggregated L2 orderbook (price levels with cumulative size):
import { MarketType, PRICE_PRECISION, BASE_PRECISION, convertToNumber } from "@velocity-exchange/sdk";
const dlob = dlobSubscriber.getDLOB();
const marketIndex = 0; // SOL-PERP
// For perp markets, use getMMOracleDataForPerpMarket (returns MMOraclePriceData)
const oraclePriceData = velocityClient.getMMOracleDataForPerpMarket(marketIndex);
const slot = slotSubscriber.getSlot();
const l2 = dlob.getL2({
marketIndex,
marketType: MarketType.PERP,
oraclePriceData,
slot,
depth: 10, // number of price levels per side
});
// l2.bids and l2.asks are arrays of { price: BN, size: BN }
console.log("Top bid:", convertToNumber(l2.bids[0].price, PRICE_PRECISION),
"size:", convertToNumber(l2.bids[0].size, BASE_PRECISION));
console.log("Top ask:", convertToNumber(l2.asks[0].price, PRICE_PRECISION),
"size:", convertToNumber(l2.asks[0].size, BASE_PRECISION));Example L2 orderbookReference ↗
Example L2 orderbookReference ↗L2 orderbook.Getting Best Bid/Ask
For quick access to the best bid and ask prices without fetching the full orderbook:
import { MarketType, PRICE_PRECISION, convertToNumber } from "@velocity-exchange/sdk";
const dlob = dlobSubscriber.getDLOB();
const marketIndex = 0;
const oraclePriceData = velocityClient.getMMOracleDataForPerpMarket(marketIndex);
const slot = slotSubscriber.getSlot();
// Returns BN | undefined (undefined if no orders on that side)
const bestBid = dlob.getBestBid(marketIndex, slot, MarketType.PERP, oraclePriceData);
const bestAsk = dlob.getBestAsk(marketIndex, slot, MarketType.PERP, oraclePriceData);
if (bestBid && bestAsk) {
console.log("Best bid:", convertToNumber(bestBid, PRICE_PRECISION));
console.log("Best ask:", convertToNumber(bestAsk, PRICE_PRECISION));
console.log("Spread:", convertToNumber(bestAsk.sub(bestBid), PRICE_PRECISION));
}Example Best bid/askReference ↗
Example Best bid/askReference ↗Best bid/ask.