Velocity ProtocolDevelopers
Market Makers

DLOB MM

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.

DLOB market making on Velocity means resting two-sided quotes on the decentralized orderbook and earning a maker rebate when takers trade against them. A maker provides liquidity with a resting order and earns the rebate; a taker removes liquidity by crossing the spread and pays the fee. Nothing stops a maker from doing both: resting quotes and JIT auction participation run side by side.

Always use post-only for maker quotes

The whole strategy depends on staying on the maker side of every fill. A quote that crosses and executes as a taker pays a fee instead of earning a rebate, which inverts the economics of the intended trade. Post-only flags are what prevent that, and Velocity offers three of them:

FlagBehaviorUse case
MUST_POST_ONLYReverts the transaction if the order would cross the spread and fill as takerDefault for MM, guarantees maker-only execution (or a clear failure to react to)
TRY_POST_ONLYTransaction still succeeds, but the order is silently not placed if it would crossUseful when skipping a stale quote beats failing the whole transaction
SLIDEAmends the price to the best non-crossing price if it would crossEnsures placement at the top of book without crossing, at whatever price that requires

Use MUST_POST_ONLY for all quotes. If the oracle moves and an order would cross, cancelling and requoting at the new price is a better outcome than taking by accident, and a reverted transaction makes that visible.

Quoting basics

A two-sided quote is a bid and an ask placed at the same time. The gap between them is the spread, and that gap is where the strategy earns. A tighter spread attracts more flow and earns less per fill; a wider one earns more per fill and sees less flow.

Each quote is an OrderParams object passed to a placement method. The fields that matter are direction (long for the bid, short for the ask), price or oraclePriceOffset, baseAssetAmount, and postOnly.

import { OrderType, PositionDirection, PostOnlyParams } from "@velocity-exchange/sdk";

await velocityClient.placePerpOrder({
  orderType: OrderType.LIMIT,
  marketIndex: 0,
  direction: PositionDirection.LONG,
  baseAssetAmount: velocityClient.convertToPerpPrecision(1),
  price: velocityClient.convertToPricePrecision(99),
  postOnly: PostOnlyParams.MUST_POST_ONLY,
});

Placing both sides in one transaction

import { MarketType, OrderType, PositionDirection, PostOnlyParams } from "@velocity-exchange/sdk";

await velocityClient.placeOrders([
  {
    orderType: OrderType.LIMIT,
    marketType: MarketType.PERP,
    marketIndex: 0,
    direction: PositionDirection.LONG,
    baseAssetAmount: velocityClient.convertToPerpPrecision(1),
    price: velocityClient.convertToPricePrecision(99.5),
    postOnly: PostOnlyParams.MUST_POST_ONLY,
  },
  {
    orderType: OrderType.LIMIT,
    marketType: MarketType.PERP,
    marketIndex: 0,
    direction: PositionDirection.SHORT,
    baseAssetAmount: velocityClient.convertToPerpPrecision(1),
    price: velocityClient.convertToPricePrecision(100.5),
    postOnly: PostOnlyParams.MUST_POST_ONLY,
  },
]);

Reading the oracle price

import { PRICE_PRECISION, convertToNumber } from "@velocity-exchange/sdk";

const oracle = velocityClient.getOracleDataForPerpMarket(0);
const oraclePrice = convertToNumber(oracle.price, PRICE_PRECISION);
console.log(oraclePrice);

Oracle offset orders

A fixed-price limit order goes stale the moment the oracle moves, so quoting with fixed prices means cancelling and replacing on every oracle tick: thousands of transactions a day, each one a chance to be late.

An oracle offset order carries an offset from the oracle price instead of a price. Its effective price moves with the oracle, so a single placement keeps tracking. A desk quoting this way sends roughly 30 transactions per day, and only to change spread or size.

How it works:

  • Keep orderType: OrderType.LIMIT: oracle tracking comes from oraclePriceOffset, not from the order type
  • Set oraclePriceOffset instead of price, this is the offset in PRICE_PRECISION units
  • Positive offset = above oracle, negative = below oracle
  • The onchain program evaluates oracle_price + offset at fill time

Don't use OrderType.ORACLE for maker quotes. Onchain, OrderType::Oracle is classified as a market/auction (taker-style) order, not a restable maker order: it's for takers who want to execute immediately at a price relative to the oracle, not for resting liquidity. Oracle-tracking for resting orders is controlled entirely by oraclePriceOffset, which works on a LIMIT order just as well. The examples below correctly use OrderType.LIMIT with oraclePriceOffset set.

oraclePriceOffset is a BN, not a number. It's stored onchain as an i64, so the SDK's OrderParams.oraclePriceOffset type is BN. Pass the BN directly (don't call .toNumber() on it). The examples below do this correctly.

import {
  BN,
  PRICE_PRECISION,
  MarketType,
  OrderType,
  PositionDirection,
  PostOnlyParams,
} from "@velocity-exchange/sdk";

const spreadOffset = 0.5; // $0.50 from oracle on each side
const offsetBN = new BN(spreadOffset * PRICE_PRECISION.toNumber());

await velocityClient.placeOrders([
  {
    orderType: OrderType.LIMIT,
    marketType: MarketType.PERP,
    marketIndex: 0,
    direction: PositionDirection.LONG,
    baseAssetAmount: velocityClient.convertToPerpPrecision(1),
    oraclePriceOffset: offsetBN.neg(), // bid: oracle - $0.50
    postOnly: PostOnlyParams.MUST_POST_ONLY,
  },
  {
    orderType: OrderType.LIMIT,
    marketType: MarketType.PERP,
    marketIndex: 0,
    direction: PositionDirection.SHORT,
    baseAssetAmount: velocityClient.convertToPerpPrecision(1),
    oraclePriceOffset: offsetBN, // ask: oracle + $0.50
    postOnly: PostOnlyParams.MUST_POST_ONLY,
  },
]);

console.log("Placed oracle offset quotes, these float with the oracle automatically!");

Tip: oracle offset orders need an update only to change spread or size. The oracle tracking is handled by the protocol at fill time.

Atomic cancel-and-replace with cancelAndPlaceOrders

When quotes do need updating (e.g., changing spread or size based on inventory), cancelAndPlaceOrders atomically cancels existing orders and places new ones in a single transaction. That avoids the window with no orders on the book that a cancel followed by a separate place leaves open.

import {
  BN,
  PRICE_PRECISION,
  MarketType,
  OrderType,
  PositionDirection,
  PostOnlyParams,
} from "@velocity-exchange/sdk";

// Atomically cancel all perp orders for market 0 and place new quotes (single tx)
const txSig = await velocityClient.cancelAndPlaceOrders(
  {
    marketType: MarketType.PERP,
    marketIndex: 0,
  },
  [
    {
      orderType: OrderType.LIMIT,
      marketType: MarketType.PERP,
      marketIndex: 0,
      direction: PositionDirection.LONG,
      baseAssetAmount: velocityClient.convertToPerpPrecision(1),
      oraclePriceOffset: new BN(-0.3 * PRICE_PRECISION.toNumber()), // tighter bid
      postOnly: PostOnlyParams.MUST_POST_ONLY,
    },
    {
      orderType: OrderType.LIMIT,
      marketType: MarketType.PERP,
      marketIndex: 0,
      direction: PositionDirection.SHORT,
      baseAssetAmount: velocityClient.convertToPerpPrecision(1),
      oraclePriceOffset: new BN(0.3 * PRICE_PRECISION.toNumber()), // tighter ask
      postOnly: PostOnlyParams.MUST_POST_ONLY,
    },
  ]
);

Inventory-aware quoting

Widening or tightening one side of the spread against current inventory reduces drift. A long position calls for a wider bid (less eager to buy more) and a tighter ask (more eager to sell).

import { BASE_PRECISION, convertToNumber } from "@velocity-exchange/sdk";

const user = velocityClient.getUser();
const position = user.getPerpPosition(0);

if (position) {
  const positionSize = convertToNumber(position.baseAssetAmount, BASE_PRECISION);
  console.log(`Position: ${positionSize} SOL`);

  // Skew spread based on inventory
  const inventorySkew = positionSize * 0.01; // $0.01 per SOL of inventory
  const bidOffset = -0.5 - Math.max(0, inventorySkew);  // widen bid when long
  const askOffset = 0.5 - Math.min(0, inventorySkew);   // tighten ask when long
}

JIT maker (onchain place-and-make)

For a bot reacting to onchain taker auctions, Velocity exposes a helper that places a maker order and fills against a taker atomically. A maker can run this alongside resting orders, a hybrid approach.

// `takerInfo` comes from the bot's taker discovery / order intake logic.
await velocityClient.placeAndMakePerpOrder(makerOrderParams, takerInfo);

Risk management basics

Common MM guardrails:

  • Position limits: max long/short size to cap directional exposure
  • Minimum free collateral: keep enough headroom to absorb adverse moves
  • Health / leverage checks: cancel all if leverage exceeds threshold
  • Emergency cancel: cancel all orders on errors, volatility spikes, or stale oracle
import { MarketType } from "@velocity-exchange/sdk";

// Cancel all orders for a specific market
await velocityClient.cancelOrders(MarketType.PERP, 0);

// Cancel ALL orders across all markets (emergency)
await velocityClient.cancelOrders();

Reference implementation

Resting orders are picked up by the hosted DLOB server, which is what backs the REST and WebSocket orderbook described in Orderbook & Matching. Making markets does not require running one.

Its source lives at apps/dlob-server in the velocity-v1 monorepo, which is not public yet. Read it to run a private instance, or to see exactly how order filtering and aggregation work.

The FloatingPerpMaker in keeper-bots-v2 is a production example of oracle offset quoting. Key patterns it demonstrates:

  • Wall-clock cooldown: waits MARKET_UPDATE_COOLDOWN_MS (12 seconds), converted to slots at the live slot duration, before requoting a market, which keeps the transaction count down
  • Mutex-guarded periodic tasks: uses async-mutex to prevent overlapping quote updates
  • Position-aware sizing: adjusts order size based on MAX_POSITION_EXPOSURE (percentage of account collateral)
  • Watchdog timer: tracks last successful update to detect stale bot state

Gotchas and production tips

  • Oracle offset precision: oraclePriceOffset is in raw PRICE_PRECISION units (1e6). An offset of 500000 = $0.50, not $500,000. Double check the math.
  • Oracle offset orders still need updates: while they track the oracle automatically, changing spread width, order size, or the number of levels still takes a cancel and replace. The cancelAndPlaceOrders method handles this atomically.
  • 32 order limit per subaccount: quoting 5 markets x 2 sides x 3 levels = 30 orders sits near the limit. Use multiple subaccounts for multi-market strategies (see JitMaker config for subaccount per market pattern).
  • MUST_POST_ONLY rejection: if the oracle moves sharply and an offset order would cross the spread, the order is rejected rather than silently filled as taker. This is the desired behavior; catch the error and requote.
  • No spot DLOB trading: Velocity removed spot order-book trading entirely (place_spot_order, place_and_make_spot_order, and fill_spot_order no longer exist onchain). Spot markets still exist for collateral and borrow-lend, but everything in this guide (placeOrders, MarketType.PERP, etc.) only applies to perp markets. There's no spot equivalent to quote against.
  • Tick size: limit and oracle-offset prices are standardized to the market's orderTickSize onchain, and the DLOB does the same when computing effective/auction prices client-side. See Orderbook & Matching: tick size if prices are computed manually rather than left for the program to clamp.

For production patterns (subscription loops, throttling, priority fees, graceful shutdown), see Bot architecture patterns.