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
This makes Velocity’s pricing more efficient than traditional limit order books.
Auction parameters
Every taker order that enters a JIT auction has three key parameters that define the auction:
| Parameter | Description |
|---|---|
auctionDuration | Number of slots the auction runs (typically 10 slots ≈ 5 seconds). After this, unfilled size falls through to DLOB/AMM. |
auctionStartPrice | The price at slot 0 of the auction. For a long, this is the best price for the taker (highest they’d pay). For a short, it’s the lowest they’d accept. |
auctionEndPrice | The price at slot N (end of auction). This is the worst price for the taker, closer to the limit price or oracle. |
The key insight: The auction starts at the taker’s best price and deteriorates toward their worst acceptable price. This creates a reverse Dutch auction: early in the auction, makers must offer great prices to fill. As the auction progresses, the bar lowers and more makers can compete.
Auction timeline
Slot 0 Slot 5 Slot 10 (end)
| | |
Best price ──────────────────► Worst price (for taker)
for taker Falls to DLOB/AMM
Hardest for Easier for Auction over,
makers to makers to remaining size
win fills win fills goes to DLOBFor a taker going LONG:
- Slot 0: auction price =
auctionStartPrice(e.g., oracle + 0.10%), taker pays above oracle - Slot 5: auction price = midpoint (e.g., oracle + 0.05%)
- Slot 10: auction price =
auctionEndPrice(e.g., oracle), taker at their limit
For a taker going SHORT:
- Slot 0: auction price =
auctionStartPrice(e.g., oracle - 0.10%), taker sells below oracle - Slot 10: auction price =
auctionEndPrice(e.g., oracle), taker at their limit
Makers who fill closer to slot 0 are giving the taker a better price (and taking more risk). Makers who wait until later slots get easier fills but at less favorable prices.
Auction pricing formula
Auction prices interpolate linearly from start to end over the auction duration:
Auction Price(slot) = start_price + (end_price - start_price) x progress
where progress = min(1, (current_slot - auction_start_slot) / auction_duration)Example (long market order, oracle at $100):
auctionStartPrice: $100.10 (oracle + 0.1%)auctionEndPrice: $100.00 (oracle)auctionDuration: 10 slots- At slot 3: price = 100.00 - 100.07
- At slot 7: price = 100.00 - 100.03
A maker offering 100.05). Makers offering $100.08 could fill as early as slot 2.
Auction lifecycle
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 slots
auctionStartPrice: velocityClient.convertToPricePrecision(100.1), // oracle + 0.1%
auctionEndPrice: velocityClient.convertToPricePrecision(100), // oracle
});Method VelocityClient.placePerpOrderReference ↗
Method VelocityClient.placePerpOrderReference ↗| Parameter | Type | Required |
|---|---|---|
orderParams | OptionalOrderParamsOrder to place; `baseAssetAmount` is BASE_PRECISION (1e9), `price` /
`triggerPrice` / `oraclePriceOffset` (signed) / `auctionStartPrice` / `auctionEndPrice` are
PRICE_PRECISION (1e6). | Yes |
txParams | TxParamsOptional compute-unit/priority-fee overrides. | No |
subAccountId | numberSub-account to place the order for; defaults to the active sub-account. | No |
isolatedPositionDepositAmount | anyIf set and the order increases the position, a transfer
into an isolated-margin position (token-mint precision) is prepended in the same transaction. | No |
| Returns |
|---|
Promise<string> |
2. Auction starts: the order enters auction for auctionDuration slots. 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 auctionDuration slots, it matches against DLOB, then AMM.
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();Class AuctionSubscriberReference ↗
Class AuctionSubscriberReference ↗AuctionSubscriber — websocket program-account subscription scoped to `User` accounts that currently have at least one order in its auction window (`getUserWithAuctionFilter`). Used by keepers/fillers to react to auction-eligible orders (JIT-fillable or approaching the end of a Dutch auction) without scanning every user account.
| Property | Type | Required |
|---|---|---|
velocityClient | any | Yes |
opts | any | Yes |
resubOpts | any | No |
eventEmitter | StrictEventEmitter<EventEmitter, AuctionSubscriberEvents> | Yes |
subscriber | any | No |
subscribe | () => Promise<void>Establishes the filtered program-account subscription (idempotent — reuses the existing subscriber if already created) and emits `onAccountUpdate` for each matching account update. | Yes |
unsubscribe | () => Promise<void>Tears down the subscription. No-op if never subscribed. | Yes |
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 your 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);
}
}
});Function getAuctionPriceReference ↗
Function getAuctionPriceReference ↗Dispatches to the correct in-progress auction price for `order` based on its order type: fixed-price auction (`getAuctionPriceForFixedAuction`) for market/triggerLimit/plain-limit orders, or oracle-offset auction (`getAuctionPriceForOracleOffsetAuction`) for oracle-pegged limit/oracle/oracle-triggered-market orders. The result is always standardized to `tickSize`.
| Parameter | Type | Required |
|---|---|---|
order | OrderOrder whose auction price to compute. | Yes |
slot | numberCurrent slot. | Yes |
oraclePrice | anyUse `MMOraclePriceData` source for perp orders, `OraclePriceData` for spot; PRICE_PRECISION (1e6). | Yes |
tickSize | anyMarket's order tick size, PRICE_PRECISION (1e6). Defaults to `ONE` (no effective standardization). | No |
Auction price at the current slot, PRICE_PRECISION (1e6).
| Returns |
|---|
BN |
3. Risk management
- Oracle validity: Reject if oracle is stale or invalid
- Position limits: Don’t fill if it exceeds your 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:
- Places your maker order onchain
- Fills against the taker order
- Settles PnL in a single transaction
This ensures you’re credited as the maker (earning rebates) while filling the taker atomically.
import { OrderType, PositionDirection, PostOnlyParams } 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,
};
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);Method VelocityClient.placeAndMakePerpOrderReference ↗
Method VelocityClient.placeAndMakePerpOrderReference ↗| Parameter | Type | Required |
|---|---|---|
orderParams | OptionalOrderParamsMaker order to place; `baseAssetAmount` is BASE_PRECISION (1e9), `price`
is PRICE_PRECISION (1e6). Must have `orderType: LIMIT`, `postOnly` set, and be IOC. | Yes |
takerInfo | TakerInfoThe taker account/order to fill against (`takerInfo.order.orderId` must be open). | Yes |
txParams | TxParamsOptional compute-unit/priority-fee overrides. | No |
subAccountId | numberSub-account placing the maker order; defaults to the active sub-account. | No |
takerEscrow | RevenueShareEscrowAccountThe taker's decoded `RevenueShareEscrow`. Required to attach the escrow
when the taker is referred but their order carries no builder code — the builder case is
detected automatically from `takerInfo.order`. | No |
| Returns |
|---|
Promise<string> |
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
Makers with better prices get priority. This pro-rata allocation ensures best execution for takers.
Auction vs DLOB
| JIT Auction | DLOB | |
|---|---|---|
| Duration | 5-10 slots (~2-5 seconds) | Orders rest indefinitely |
| Pricing | Dynamic, interpolates toward oracle | Fixed price set at placement |
| Commitment | None until fill, makers choose per-auction | Onchain, orders are committed |
| Best for | Active makers, flow-selective strategies | Passive makers, committed liquidity |
| Priority | Runs first | Fallback after auction |
JIT runs first, DLOB provides backup liquidity if auction doesn’t fully fill.
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 5-10 slot delay (2-5 seconds) before execution
- You get better prices but not instant fills
- Use market orders for auction participation (limit orders bypass auction if they don’t cross spread)
AMM as a JIT maker
The AMM isn’t only the fallback of last resort (see Orderbook & Matching) — inside a DLOB match it can also compete as a JIT maker against your resting order for the same fill. Whether it does depends on a set of hard gates checked at fill time:
AmmFillisn’t paused for the market (PerpOperation.AMM_FILL)- the AMM doesn’t currently have too much drawdown
- the oracle (including the MM oracle, when active and recent) is valid and not too volatile vs. the exchange oracle
If any of these gates trips, the AMM is excluded from that DLOB match entirely — you’re only competing against other DLOB makers for that fill, not the AMM. This is separate from the auction-timing rules that decide whether the AMM can fill a fresh taker order immediately outside of a DLOB match. Practically: don’t assume the AMM is always a competing quote inside the auction — during a pause, drawdown, or oracle-stress event it drops out, and DLOB-only makers pick up the full remaining size.
JIT Proxy library
Not yet published to npm. @velocity-exchange/jit-proxy isn’t on the npm registry yet — build it from packages/jit-proxy in the velocity-v1 monorepo until it is. The client is vendored in-repo, ported to Anchor 1.0, and built against @velocity-exchange/sdk; it exposes the same JitProxyClient / JitterSniper / JitterShotgun API as upstream.
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.
// Note: the constructor field is still named `driftClient` even though it's typed
// as `VelocityClient` -- @velocity-exchange/jit-proxy hasn't renamed the field yet.
const jitter = new JitterSniper({
auctionSubscriber,
driftClient: velocityClient,
// ...
});
// Shotgun: multiple fill attempts across auction slots
const jitter = new JitterShotgun({
auctionSubscriber,
driftClient: velocityClient,
// ...
});Gotchas
- Auction slots ≠ wall-clock time: auction duration is measured in Solana slots (~400ms each), not seconds. Network congestion can stretch slot times, affecting your timing assumptions.
- Partial fills are common: multiple makers compete for the same auction. Your fill may be partial; handle
baseAssetAmountFilled < baseAssetAmountgracefully. - Compute budget for place-and-make: these transactions are heavier than simple order placement. Budget 400-800k CU (the
JitMakerdefaults to 800k). Under-budgeting causes silent failures. - Stale
takerInfo: if you hold a taker reference too long, the taker’s order may already be filled or cancelled. Checkorder.baseAssetAmount - order.baseAssetAmountFilledfor remaining size.
Related
- Orderbook & Matching - DLOB and liquidity priority (JIT → DLOB → AMM)
- Matching Engine - Full liquidity priority flow
- JIT-only MM - Building a JIT market maker bot
- SWIFT API - See taker orders 100-500ms before they hit the auction
packages/jit-proxy- JIT proxy SDK (@velocity-exchange/jit-proxy, unpublished — build from source)