Tutorial: Order Matching Bot
Introduction
Order Matching Bots (Matching Bots) are responsible for matching two orders that cross or a taker order against the AMM. Specifically, this includes:
-
Market Orders: Market Buy and Market Sell
-
Limit Orders: Limit Buy and Limit Sell
Matching Bots receive a small compensation for each order that they successfully fill.
See Keepers & Decentralised Orderbook for a technical explanation of how the decentralised orderbook (DLOB) and matching incentives work.
Matching Bots are similar to Tutorial: Order Trigger Bot in that they:
-
also maintain a local copy of the Decentralised Limit Orderbook (DLOB);
-
do not require the operator to manage collateral; and
-
receive a small reward for performing their duties.
Getting Started
The reference implementation of the Order Matching Bot is available here in the velocity-v1 monorepo.
Follow Keeper Bots to set required environment variables and initialize a Velocity user account.
Start the Matching Bot:
bun run dev:fillerTechnical Explanation
The matching bot runs a continuous loop: fetch fillable orders from the DLOB, filter out non-actionable ones, and submit fill transactions to earn keeper rewards.
Get nodes from the DLOB that are ready to be filled
Market orders first go through JIT Auctions. Most orders become available to matching bots once the auction period ends, but the AMM can also skip the rest of the auction early when its inventory is low and it has been profitable since the last funding update, so a crossing DLOB maker or the AMM itself can fill an order before the auction ends. The DLOB exposes a findNodesToFill method that returns eligible orders, using the current virtual bid/ask and oracle price to determine which orders can be matched.
const market = this.velocityClient.getPerpMarketAccount(marketIndex)!;
const slot = this.slotSubscriber.getSlot();
const oraclePriceData = this.velocityClient.getMMOracleDataForPerpMarket(marketIndex, slot);
const slotDuration = currentSlotDuration(this.velocityClient, slot);
const vAsk = calculateAskPrice(market, oraclePriceData, new BN(slot), slotDuration);
const vBid = calculateBidPrice(market, oraclePriceData, new BN(slot), slotDuration);
const nodesToFill = this.dlob.findNodesToFill(
marketIndex,
vBid,
vAsk,
slot,
Date.now() / 1000, // unix ts, used to find expired orders
MarketType.PERP,
oraclePriceData,
this.velocityClient.getStateAccount(),
market
);Pass the live slot to getMMOracleDataForPerpMarket: omitting it falls back to a best-effort observed slot that can stall while a market is idle. Pass slot and slotDuration to calculateAskPrice/calculateBidPrice too, so the vAMM is quoted off the projected post-refresh curve, which is what the program uses to route fills.
Filter for fillable nodes
Not every node returned is profitable to attempt. Skip orders below the market’s minimum step size against the AMM: submitting a fill for these would waste transaction fees and fail on-chain.
if (
!nodeToFill.makerNode &&
(isVariant(nodeToFill.node.order.orderType, "limit") ||
isVariant(nodeToFill.node.order.orderType, "triggerLimit"))
) {
const remainingBaseAssetAmount = nodeToFill.node.order.baseAssetAmount.sub(
nodeToFill.node.order.baseAssetAmountFilled
);
if (remainingBaseAssetAmount.lt(market.orderStepSize)) {
// skip order
continue;
}
}Call getFillPerpOrderIx on VelocityClient
Submit the fill transaction. On success, the keeper earns a small reward. Expect occasional failures from competing bots filling the same order: handle errors gracefully and continue to the next node.
const user = this.userMap.get(nodeToFill.node.userAccount.toString());
const takerStats = this.userStatsMap.get(user.getUserAccount().authority.toString());
const takerIsReferred = takerStats ? isBuilderReferral(takerStats.getAccount()) : false;
const ix = await this.velocityClient.getFillPerpOrderIx(
nodeToFill.node.userAccount,
user.getUserAccount(),
nodeToFill.node.order,
undefined, // makerInfo
undefined, // fillerSubAccountId
undefined, // isSignedMsg
undefined, // fillerAuthority
undefined, // hasBuilderFee, derived from the order's bitflags
undefined, // takerEscrow
takerIsReferred
);
const tx = await this.velocityClient.buildTransaction(ix);
const { txSig } = await this.velocityClient.sendTransaction(tx);The program’s fill gate rejects a fill that omits the taker’s RevenueShareEscrow when the order carries a builder code or the taker is referred (UserStats.referrerStatus’s BuilderReferral bit). Omitting it when required reverts the transaction with UnableToLoadRevenueShareAccount. Pass takerIsReferred (via isBuilderReferral) so the program can find the escrow it needs; hasBuilderFee is otherwise derived automatically from the order.