Velocity ProtocolDevelopers
Market Makers

JIT Auctions

JIT (Just-In-Time) auctions are Velocity's price discovery mechanism. When a taker order arrives (market order or aggressive limit crossing the spread), it enters an auction where market makers compete to fill it at better prices before it hits the DLOB or AMM.

Why JIT auctions?

Without JIT, taker orders would immediately execute against resting DLOB orders or the AMM at potentially worse prices. JIT auctions:

  • Improve price execution for takers by giving makers time to offer better prices
  • Reduce adverse selection by letting makers react to toxic flow
  • Increase competition among market makers for the same fill
  • Enable offchain quoting where makers don't need to rest orders, just respond to auctions

Auction parameters

Every taker order that enters a JIT auction has three key parameters that define the auction:

ParameterDescription
auctionDurationHow long the auction runs, in wall-clock units of 400ms (a caller-requested value can be raised by order sanitization to a market/tier-derived floor; see the gotcha below). After this, unfilled size falls through to DLOB/AMM.
auctionStartPriceThe price at the start of the auction. For a long, this is the best price for the taker (lowest they'd pay). For a short, it's the highest they'd receive.
auctionEndPriceThe price at the end of the auction. This is the worst price for the taker, at their limit price (closer to, or past, the oracle).

The key insight: For a long, auctionStartPrice must be less than or equal to auctionEndPrice (the program rejects the order with InvalidOrderAuction otherwise); for a short, auctionStartPrice must be greater than or equal to auctionEndPrice. The auction starts at the taker's best price and ramps toward their worst acceptable price as time passes: early in the auction, makers must offer a price close to the taker's best price to win the fill. As the auction progresses, the price moves toward the taker's limit and more makers can compete profitably.

Auction timeline

How the auction price moves

Auction, 10 units (4s)0510400ms units since the auction starts100.00100.05100.10Auction price, USDOracle, $100.00Auction price, a straight line from $100.00 at unit 0 to $100.10 at unit 10After unit 10 the auction is over and the rest of the order can fill at the $100.10 limit until it expiresMaker fills at $100.05Maker fills at $100.05
A long market order with the oracle at $100.00, an auction start price of $100.00, an end price of $100.10 (the taker's limit), and an auctionDuration of 10 (10 x 400ms = 4 seconds). The auction price moves in a straight line from start to end, so a maker quoting $100.05 can fill from 2 seconds in onward. After 4 seconds the auction is over and any size still unfilled can fill at the limit price until the order expires, drawn as the dashed line. The numbers are the worked example on this site, not live market data. The unit is wall clock, not a live slot, and the program can raise a requested duration, so a real auction may run longer than 4 seconds.

The figure uses the worked example of a 10-unit auction (10 x 400ms = 4 seconds) from $100.00 to $100.10. The unit is wall-clock time, not a live slot, so the auction lasts the same 4 seconds whatever the current slot duration; see Slot duration and wall-clock time.

For a taker going LONG:

  • Unit 0: auction price = auctionStartPrice (e.g., oracle), taker pays at or near oracle
  • Unit 5: auction price = midpoint (e.g., oracle + 0.05%)
  • Unit 10: auction price = auctionEndPrice (e.g., oracle + 0.10%), taker at their limit

For a taker going SHORT:

  • Unit 0: auction price = auctionStartPrice (e.g., oracle), taker sells at or near oracle
  • Unit 10: auction price = auctionEndPrice (e.g., oracle - 0.10%), taker at their limit

Makers who fill closer to the start are giving the taker a better price (and taking more risk). Makers who wait get easier fills but at less favorable prices.

Auction pricing formula

Auction prices interpolate linearly from start to end over the auction duration. The program measures elapsed slots and converts them to wall-clock milliseconds through the live slot duration before comparing them against auctionDuration:

Auction Price(t) = start_price + (end_price - start_price) x progress

where elapsed_ms  = wall clock elapsed since auction_start_slot
      duration_ms = auction_duration x 400
      progress    = min(1, elapsed_ms / duration_ms)

Example (long market order, oracle at $100):

  • auctionStartPrice: $100.00 (oracle)
  • auctionEndPrice: $100.10 (oracle + 0.1%, the taker's limit)
  • auctionDuration: 10 (4 seconds)
  • At 1.2s elapsed: price = $100.00 + ($100.10 - $100.00) x 0.3 = $100.03
  • At 2.8s elapsed: price = $100.00 + ($100.10 - $100.00) x 0.7 = $100.07

A maker offering $100.05 would be eligible to fill from 2 seconds in (when the auction price reaches $100.05). Makers offering $100.02 could fill as early as 0.8 seconds in.

Auction lifecycle

One taker order through a JIT auction

Takeroff-chainProgramon-chainEvent feedon-chainJIT makersoff-chainKeeperoff-chainAuction opensAuction windowAuction ends unfilledTaker to Program: Order with auction paramsOrder withauction paramsProgram to Event feed: Taker order eventTaker ordereventEvent feed to JIT makers: New auctionNew auctionJIT makers to itself: Price at this slotPrice at thisslotJIT makers to Program: Place and make fillPlace and make fillProgram to Taker: Filled at auction priceFilled at auctionpriceAny maker can fill any part of the order, first come firstserved, so partial fills are normal.Keeper to Program: Fill from DLOB or AMMFill from DLOB or AMMResting orders and the AMM are matched by price ateach level, not in a fixed order. The taker can submit thisfill themselves instead.Program to Taker: Fill, or the order expiresFill, or the orderexpires
Shows the order of events for a single taker order, from placement to a maker fill or to the fallback after the auction. Sources: the JIT FAQ, JIT Auctions, and Matching Engine pages in these docs. The event feed is the on-chain event emitter that makers subscribe to. The auction price ramps from the taker's best price toward their limit as slots pass, so filling early costs a maker more. Durations are counted in Solana slots, not seconds, and a limit order still open when its auction ends rests on the DLOB, where it can then fill as a maker.

1. Taker places order

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

// oraclePrice and auction prices are BN, PRICE_PRECISION (1e6)
await velocityClient.placePerpOrder({
  orderType: OrderType.MARKET,
  direction: PositionDirection.LONG,
  baseAssetAmount: size,
  auctionDuration: 10,                                            // 10 x 400ms = 4s (may be raised by sanitization)
  auctionStartPrice: velocityClient.convertToPricePrecision(100),   // oracle
  auctionEndPrice: velocityClient.convertToPricePrecision(100.1),   // limit, oracle + 0.1%
});

2. Auction starts: the order enters auction for auctionDuration x 400ms of wall clock. The auction price interpolates from auctionStartPrice toward auctionEndPrice.

3. Market makers compete: makers observe the auction and submit fills at prices within the auction range.

// AuctionSubscriber has no getAuction()/pull-style API -- it's event-driven.
// It emits 'onAccountUpdate' with the taker's UserAccount whenever an order changes.
auctionSubscriber.eventEmitter.on("onAccountUpdate", async (takerUserAccount, pubkey, slot) => {
  // Inspect takerUserAccount.orders for the specific order in auction, then:
  await velocityClient.placeAndMakePerpOrder(
    makerOrderParams,
    takerInfo // includes taker's order and user account
  );
});

4. Auction resolves: best maker(s) fill the taker. If partially filled, remaining size continues through the auction. If unfilled after the auctionDuration window elapses, remaining size can fill against resting DLOB orders and the AMM, matched by price at each level rather than in a fixed order (see Orderbook & Matching).

Maker participation

To participate in JIT auctions, bots typically:

1. Subscribe to auction feed

import { AuctionSubscriber } from "@velocity-exchange/sdk";

const auctionSubscriber = new AuctionSubscriber({
  velocityClient,
  opts: { commitment: "processed" }
});

await auctionSubscriber.subscribe();

2. Filter and price auctions

import { getAuctionPrice, isVariant } from "@velocity-exchange/sdk";

// AuctionSubscriber emits 'onAccountUpdate' with UserAccount data
auctionSubscriber.eventEmitter.on("onAccountUpdate", (userAccount, pubkey, slot) => {
  // Find orders in auction (hasAuction flag is set by the memcmp filter)
  for (const order of userAccount.orders) {
    if (
      !isVariant(order.status, "open") ||
      order.baseAssetAmount.eq(order.baseAssetAmountFilled)
    ) continue;

    // Calculate current auction price at this slot. Pass the market's tick size
    // (PerpMarketAccount.orderTickSize) so this matches the program's own rounding --
    // see Orderbook & Matching's tick-size section for why this matters.
    const perpMarket = velocityClient.getPerpMarketAccount(order.marketIndex);
    const oracleData = velocityClient.getMMOracleDataForPerpMarket(order.marketIndex);
    const auctionPrice = getAuctionPrice(order, slot, oracleData.price, perpMarket.orderTickSize);

    // Check if the desired fill price is within the current auction range
    const myFillPrice = calculateMyPrice(oracleData, inventory);

    if (isProfitable(myFillPrice, auctionPrice, order.direction)) {
      fillAuction(order, userAccount, pubkey, myFillPrice);
    }
  }
});

3. Risk management

  • Oracle validity: Reject if oracle is stale or invalid
  • Position limits: Skip the fill if it would push the bot past its max position
  • Toxic flow detection: Skip auctions from certain patterns
  • Inventory skew: Adjust participation based on current inventory

Place-and-make pattern

The placeAndMakePerpOrder instruction atomically:

  1. Places the maker order onchain
  2. Fills against the taker order
  3. Settles P&L in a single transaction

The order is credited as the maker side, earning rebates, while the taker is filled in the same transaction.

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

const makerOrderParams = {
  orderType: OrderType.LIMIT,
  marketIndex: auction.order.marketIndex,
  direction: PositionDirection.SHORT, // opposite of taker's LONG
  price: velocityClient.convertToPricePrecision(myFillPrice),
  baseAssetAmount: auction.order.baseAssetAmount, // fill entire order
  postOnly: PostOnlyParams.MUST_POST_ONLY,
  // Required: the program rejects any place-and-make maker order that
  // isn't IOC + post-only + limit with InvalidOrderIOCPostOnly.
  bitFlags: OrderParamsBitFlag.ImmediateOrCancel,
};

const takerInfo = {
  taker: takerPubkey,                // PublicKey of taker's user account
  takerStats: takerStatsPubkey,      // PublicKey of taker's UserStats PDA
  takerUserAccount: takerUserAccount, // decoded UserAccount
  order: takerOrder,                  // the specific Order to fill
};

await velocityClient.placeAndMakePerpOrder(makerOrderParams, takerInfo);

Multi-maker fills

Multiple makers can fill the same taker order:

  • Maker A fills 30% at oracle + 0.03%
  • Maker B fills 50% at oracle + 0.01%
  • Remaining 20% hits DLOB or AMM

Fills are allocated sequentially in price order, not split pro rata: the fill plan walks the price-sorted maker list and fills each maker in turn, with ties broken by DLOB arrival order.

Auction vs DLOB

JIT AuctionDLOB
DurationauctionDuration x 400ms of wall clockOrders rest indefinitely
PricingDynamic, interpolates toward oracleFixed price set at placement
CommitmentNone until fill, makers choose per-auctionOnchain, orders are committed
Best forActive makers, flow-selective strategiesPassive makers, committed liquidity
After the windowUnfilled size falls through to resting orders and the AMMThe order stays on the book until filled or cancelled

Once the auction window elapses, whatever is left is matched by price at each level rather than in a fixed source order. See Orderbook & Matching.

Performance considerations

For makers:

  • Subscribe with commitment: "processed" for lowest latency
  • Use WebSocket or gRPC subscriptions (not polling)
  • Pre-compute oracle prices and risk checks
  • Keep fills under compute budget (400k CU typical)

For takers:

  • Auction adds a delay before execution. An auctionDuration of 5 to 10 units is 2 to 4 seconds of wall clock
  • The price improvement is paid for with that delay: an auctioned order is not an instant fill
  • Use market orders for auction participation (limit orders bypass auction if they don't cross spread)

The vAMM as a competing quote

The AMM isn't only the fallback of last resort. determine_perp_fulfillment_methods walks the crossing makers in price order and inserts an AMM step ahead of any maker the AMM out-prices, capped at that maker's price, before falling back to a residual AMM step at the end (see Orderbook & Matching).

Two things about it matter when a maker prices against it:

  • It jumps ahead of a maker it beats. If the AMM's bid or ask (spread and reference price offset included) is better than the maker's resting price, the taker's size hits the AMM first, at the maker's price, not the AMM's own. Being on the book is not enough: a maker has to beat the AMM's quote to see the fill.
  • It also competes inside the match. When amm_jit_allowed holds, the Match step itself builds an AmmJitQuoter alongside the maker's order and the two split that fill. Those fills are recorded with OrderActionExplanation.OrderFilledWithAMMJit.

Whether the vAMM participates at all depends on hard 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
  • the oracle (including the MM oracle, when active and recent) is valid and not too volatile vs. the exchange oracle

If any gate trips, amm_can_fill_order returns false and the fill plan contains Match steps only. Practically: don't assume the vAMM is always a competing quote. During a pause, drawdown, or oracle-stress event it drops out, and the other makers pick up the full remaining size.

JIT Proxy library

@velocity-exchange/jit-proxy is on npm. It declares two peer dependencies and does not install them for you. Anchor is pinned to @anchor-lang/core@1.0.1 under the @coral-xyz/anchor alias, the same form the SDK uses, so installing @coral-xyz/anchor unaliased pulls the wrong Anchor:

bun add @velocity-exchange/jit-proxy
bun add @coral-xyz/anchor@npm:@anchor-lang/core@1.0.1 @solana/web3.js@1.98.0

The client is ported to Anchor 1.0 and built against @velocity-exchange/sdk; it exposes the same JitProxyClient / JitterSniper / JitterShotgun API as upstream. Source lives at packages/jit-proxy in the velocity-v1 monorepo, which is not public yet, so npm is the only way to get it.

The package provides higher-level abstractions for auction participation:

  • JitterSniper: waits for the optimal auction slot before submitting a single fill transaction. Best for precise pricing with lower compute costs.
  • JitterShotgun: submits fill transactions at multiple auction slots simultaneously. Higher fill rate but uses more compute and SOL for fees.

The JitMaker bot in keeper-bots-v2 demonstrates both strategies and includes market volatility checks, position sizing, and DLOB-aware pricing.

import { JitterSniper, JitterShotgun, PriceType } from "@velocity-exchange/jit-proxy";

// Sniper: one precise fill attempt.
const jitter = new JitterSniper({
  auctionSubscriber,
  velocityClient,
  // ...
});

// Shotgun: multiple fill attempts across auction slots
const jitter = new JitterShotgun({
  auctionSubscriber,
  velocityClient,
  // ...
});

Gotchas

  • auctionDuration is wall clock, not slots: the field stores 400ms units of wall-clock time, so auctionDuration: 10 is 4 seconds and stays 4 seconds as Solana's slot time steps down from 400ms toward 200ms. get_auction_duration clamps the sanitized value to the range 1 to 180 (72 seconds) and applies no slot conversion. At fill time the program converts elapsed slots into wall clock through the live slot duration on State, so a shorter slot time means more slots inside the same auction, not a shorter auction. Do not multiply auctionDuration by the current slot time to get seconds: multiply by 400ms.
  • Requested duration can be raised: the auctionDuration passed in is a floor, not a guarantee. Two separate rules can raise it, so the onchain value may exceed the requested one. Order sanitization raises it to a minimum derived from the requested price range and the market's contract tier: tier A and tier B grant 100 units per 1% of range, lower tiers 60 units, clamped to 1 to 180 units. On top of that, placement raises it to the exchange-wide floor held in State.minPerpAuctionDuration; read the live State account for that value. Market and oracle orders always get at least that floor. A limit order placed with an explicit auctionDuration of 0 keeps 0 and rests immediately, but any non-zero value on a limit order is raised to the floor too, so a requested duration below the floor never survives placement.
  • Partial fills are common: multiple makers compete for the same auction. A fill may be partial; handle baseAssetAmountFilled < baseAssetAmount gracefully.
  • Compute budget for place-and-make: these transactions are heavier than simple order placement. Budget 400-800k CU (the JitMaker defaults to 800k). Under-budgeting causes silent failures.
  • Stale takerInfo: a taker reference held too long can point at an order that is already filled or cancelled. Check order.baseAssetAmount - order.baseAssetAmountFilled for remaining size.
  • Orderbook & Matching: DLOB and how JIT, resting, and AMM liquidity compete on price
  • Matching Engine: Full liquidity priority flow
  • JIT-only MM: Building a JIT market maker bot
  • SWIFT API: see taker orders 100 to 500 ms before they hit the auction
  • @velocity-exchange/jit-proxy: JIT proxy SDK, on npm