PredictionLabs

Running a Keeper

How to run order matching bots and settlement bots for the protocol.

Keepers are bots that match orders and settle assertions. The protocol is designed for permissionless keepers - anyone can run one.

Order matching

The opportunity

Every fill has a 0.1% fee. Keepers don't pay or earn the fee - they just execute matches. The incentive is that keepers who also trade can ensure their own orders get filled, or they can earn from spread capture by placing and filling their own orders strategically.

Discovery

Poll for active orders using events (see Building a Frontend - Building the order book). Then find crossable pairs:

Buy vs Buy - two BUY orders on opposite outcomes where price0 + price1 >= 1e6:

function findBvBMatches(orders, conditionId) {
  const yes = orders.filter(o =>
    o.conditionId === conditionId && o.outcomeIndex === 0n && o.side === 0n && !o.cancelled && o.filled < o.amount
  );
  const no = orders.filter(o =>
    o.conditionId === conditionId && o.outcomeIndex === 1n && o.side === 0n && !o.cancelled && o.filled < o.amount
  );

  const matches = [];
  for (const y of yes) {
    for (const n of no) {
      if (y.price + n.price >= 1_000_000n) {
        const yRem = y.amount - y.filled;
        const nRem = n.amount - n.filled;
        const fill = yRem < nRem ? yRem : nRem;
        matches.push({ buyId0: y.orderId, buyId1: n.orderId, fillAmount: fill });
      }
    }
  }
  return matches;
}

Buy vs Sell - a BUY and SELL on the same outcome where buyPrice >= sellPrice:

function findBvSMatches(orders, conditionId, outcomeIndex) {
  const buys = orders.filter(o =>
    o.conditionId === conditionId && o.outcomeIndex === BigInt(outcomeIndex) &&
    o.side === 0n && !o.cancelled && o.filled < o.amount
  );
  const sells = orders.filter(o =>
    o.conditionId === conditionId && o.outcomeIndex === BigInt(outcomeIndex) &&
    o.side === 1n && !o.cancelled && o.filled < o.amount
  );

  const matches = [];
  for (const buy of buys) {
    for (const sell of sells) {
      if (buy.price >= sell.price) {
        const buyRem = buy.amount - buy.filled;
        const sellRem = sell.amount - sell.filled;
        const fill = buyRem < sellRem ? buyRem : sellRem;
        matches.push({ buyId: buy.orderId, sellId: sell.orderId, fillAmount: fill });
      }
    }
  }
  return matches;
}

Execution

// Buy vs Buy
for (const match of bvbMatches) {
  try {
    await orderBook.fillBuyVsBuy(match.buyId0, match.buyId1, match.fillAmount);
  } catch (e) {
    // Order may have been filled or cancelled since discovery
    console.error(`Fill failed: ${e.message}`);
  }
}

// Buy vs Sell
for (const match of bvsMatches) {
  try {
    await orderBook.fillBuyVsSell(match.buyId, match.sellId, match.fillAmount);
  } catch (e) {
    console.error(`Fill failed: ${e.message}`);
  }
}

Minimum fill

Fills must be at least MIN_ORDER_COST (1e4) shares unless it's the final fill that completes an order. The contract checks the share amount directly, not the USDC cost.

const MIN_FILL = 10_000n; // 1e4 shares
const remaining0 = order0.amount - order0.filled;
const remaining1 = order1.amount - order1.filled;
// Skip dust fills unless it completes one side entirely
if (fill < MIN_FILL && fill < remaining0 && fill < remaining1) {
  continue; // skip dust fill
}

Settlement bot

After the liveness window expires, assertions need to be settled. Anyone can call settleAssertion on UMA-based resolvers (UMAResolver, KalshiResolver). Both use a 2-hour liveness period. PolymarketResolver does not use assertions - it resolves instantly via resolve() with no liveness window.

Watch for assertions

Monitor ResolutionAsserted events across UMA-based resolvers (UMAResolver, KalshiResolver). Each market's resolver is stored in the MarketCreated event. PolymarketResolver does not emit ResolutionAsserted - it resolves atomically via resolve().

import UMAResolverABI from "./abi/UMAResolver.json";
import KalshiResolverABI from "./abi/KalshiResolver.json";

// Known UMA-based resolver addresses (UMAResolver and KalshiResolver)
const UMA_RESOLVERS = new Set([UMA_RESOLVER_ADDRESS, KALSHI_RESOLVER_ADDRESS]);

// Build resolver contracts only for UMA-based resolvers
const resolvers = new Map(); // resolverAddress => ethers.Contract

for (const market of markets) {
  if (UMA_RESOLVERS.has(market.resolver) && !resolvers.has(market.resolver)) {
    const abi = market.resolver === KALSHI_RESOLVER_ADDRESS ? KalshiResolverABI : UMAResolverABI;
    const resolver = new ethers.Contract(market.resolver, abi, provider);
    resolvers.set(market.resolver, resolver);
  }
  // PolymarketResolver resolves atomically via resolve()  - no settlement needed
}

// Watch each UMA-based resolver for assertions
for (const [address, resolver] of resolvers) {
  resolver.on("ResolutionAsserted", async (conditionId, assertionId, assertedOutcome, asserter) => {
    const livenessMs = 2 * 60 * 60 * 1000; // 2 hours
    const settleAt = Date.now() + livenessMs + 30_000; // + 30s buffer

    setTimeout(async () => {
      try {
        await resolver.settleAssertion(assertionId);
        console.log(`Settled assertion ${assertionId} for market ${conditionId}`);
      } catch (e) {
        // May have been disputed or already settled
        console.error(`Settlement failed: ${e.message}`);
      }
    }, settleAt - Date.now());
  });
}

Expired order cleanup

After a market resolves, stale orders can be cancelled by anyone. This frees locked collateral for makers who forgot to cancel. Listen for MarketResolved events on the MarketFactory (not on individual resolvers).

factory.on("MarketResolved", async (conditionId) => {
  // Find all active orders on this market
  const staleOrders = [...orders.values()].filter(o =>
    o.conditionId === conditionId && !o.cancelled && o.filled < o.amount
  );

  for (const order of staleOrders) {
    await orderBook.cancelExpiredOrder(order.orderId);
  }
});

Gas considerations

On Polygon, gas is cheap (fractions of a cent per transaction). Key costs:

OperationApproximate gas
fillBuyVsBuy~200k-300k (includes CTF splitPosition)
fillBuyVsSell~150k-200k
settleAssertion~200k-300k (includes CTF reportPayouts callback)
cancelExpiredOrder~50k-100k

At typical Polygon gas prices (30-50 gwei), each fill costs well under $0.01.

Architecture

A production keeper typically:

  1. Indexes events at startup to build current order book state
  2. Subscribes to new events via WebSocket for real-time updates
  3. Runs a matching loop that checks for crossable orders on every update
  4. Handles failures gracefully - orders can be filled or cancelled between discovery and execution
  5. Monitors assertions across all active resolvers for settlement after liveness expiry

Keep it simple. The matching logic is straightforward - the hard part is speed, not complexity.

On this page