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 and the decentralized orderbook for how the decentralized orderbook (DLOB) is built and how matching incentives work.
Matching Bots are similar to Tutorial: Order Trigger Bot in that they:
- also maintain a local copy of the DLOB
- do not require the operator to manage collateral
- receive a small reward for performing their duties
Getting Started
The reference implementation is src/bots/filler.ts in apps/keeper-bots-v2, inside the velocity-v1 monorepo, which is not public yet.
Set the environment variables and initialize a Velocity user account as described in Trading Automation. Then 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 onchain.
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.
Two conditions bound that gate, and both matter to a filler:
- It only runs while the
BuilderCodesbit is set onState.featureBitFlags. An admin sets and clears that bit. While it is clear no fill path loads the escrow and passing one changes nothing; while it is set, omitting a required escrow reverts the fill. Read the bit rather than hardcoding either behavior. See Builder Codes. - It is skipped entirely on liquidation fills.
liquidatePerpWithFillnever loads an escrow, so a liquidation fill needs no escrow account regardless of the taker's referral status.
The gate exists because the escrow is an optional account. Without it a keeper could omit the escrow and the fill would still succeed, with the referrer's reward and the referee's discount silently resolving to zero rather than failing.