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:
| Parameter | Description |
|---|---|
auctionDuration | How 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. |
auctionStartPrice | The 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. |
auctionEndPrice | The 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
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
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:
- Places the maker order onchain
- Fills against the taker order
- 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 Auction | DLOB | |
|---|---|---|
| Duration | auctionDuration x 400ms of wall clock | 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 |
| After the window | Unfilled size falls through to resting orders and the AMM | The 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
auctionDurationof 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_allowedholds, theMatchstep itself builds anAmmJitQuoteralongside the maker's order and the two split that fill. Those fills are recorded withOrderActionExplanation.OrderFilledWithAMMJit.
Whether the vAMM participates at all depends on hard gates checked at fill time:
AmmFillisn'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.0The 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
auctionDurationis wall clock, not slots: the field stores 400ms units of wall-clock time, soauctionDuration: 10is 4 seconds and stays 4 seconds as Solana's slot time steps down from 400ms toward 200ms.get_auction_durationclamps 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 onState, so a shorter slot time means more slots inside the same auction, not a shorter auction. Do not multiplyauctionDurationby the current slot time to get seconds: multiply by 400ms.- Requested duration can be raised: the
auctionDurationpassed 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 inState.minPerpAuctionDuration; read the liveStateaccount for that value. Market and oracle orders always get at least that floor. A limit order placed with an explicitauctionDurationof0keeps0and 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 < 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: a taker reference held too long can point at an order that is already filled or cancelled. Checkorder.baseAssetAmount - order.baseAssetAmountFilledfor remaining size.
Related
- 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