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:
| Operation | Approximate 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:
- Indexes events at startup to build current order book state
- Subscribes to new events via WebSocket for real-time updates
- Runs a matching loop that checks for crossable orders on every update
- Handles failures gracefully - orders can be filled or cancelled between discovery and execution
- 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.