Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Configuration Reference

The keeper reads one YAML file, bazaar-keeper.yaml by default (-c <path> names another). This page lists every section and every key, with its type, its default and what it does. Defaults are the schema’s (packages/keeper/src/config/schema.ts): a key you leave out takes its default, and so does a whole section.

bazaar keeper init writes a working file for you (Setting Up a Keeper); the repository’s bazaar-keeper.example.yaml shows the common options with comments. A complete annotated example is at the end of this page.

How the File Is Read

  1. The .env file in the config’s directory is loaded into the environment. Variables already set in the environment win.
  2. The YAML is parsed. It must be a mapping.
  3. The secrets rules are checked on the text as written: secret keys must be ${NAME} references, URLs that may carry a key must come from the environment, and prices.headers values must be references.
  4. Every ${NAME} and ${NAME:-default} in a string value is filled from the environment.
  5. The result is validated against the schema. Unknown keys are refused everywhere, so a typo is an error, not a silently ignored setting. Every problem is listed at once:
the config is not valid:
  rpc: Unrecognized key(s) in object: 'chunk'
  wallets.default: a keystore wallet needs path
  config: job liquidation uses wallet 'fast', which is not under wallets

The library’s parseConfig(object) does step 5 only (The Keeper as a Library).

Three YAML details matter here:

  • Quote addresses. YAML reads an unquoted 0x… as a hexadecimal number. Write factory: "0x3Aa5ebB10DC797CAC828524e59A333d0A371443c".
  • An empty job turns it off. liquidation: with nothing under it is null, and a null job is disabled. Write liquidation: {} to enable a job with its defaults.
  • Use block style for references. ${NAME} inside a flow mapping ({ botToken: ${TG_BOT_TOKEN} }) does not parse; write the mapping on separate lines, or quote the value.

Where

KeyTypeDefaultMeaning
networkstringrequiredarbitrum-one, arbitrum-sepolia, anvil, or custom
chainIdintegerthe preset’srequired for custom; on a preset it replaces the preset’s chain id
factoryaddressthe preset’sthe deployment’s factory. Required for custom, and for any preset without an official factory (at present all of them)
startBlockinteger ≥ 0see belowwhere a first start without a subgraph begins reading logs: the factory’s deployment block
PresetChain idSequencer endpoint (rpc.send: sequencer)Official factory
arbitrum-one42161https://arb1-sequencer.arbitrum.io/rpcnot yet
arbitrum-sepolia421614nonenot yet
anvil31337nonenever (a local chain)

The keeper reads everything else from the factory: the lens, the terminator, the oracle, USDC, every market and its price feeds. It refuses to start if the lens or the terminator belongs to a different factory. A factory that is not a network’s official one is used all the same, with a warning (factory 0x… is not an official deployment) in the log, doctor and status.

startBlock left out. Nothing of Bazaar’s emits a log before its factory exists, so the scan can start at the factory’s deployment block. When startBlock is left out (and no official preset gives it), a first start without a subgraph finds that block on chain: a binary search on eth_getCode at historical blocks, about 30 reads on Arbitrum One. It logs found the factory's deployment block, N (set startBlock: N in the config to skip the search). The search needs an RPC that keeps historical state (hosted providers do). A node that has pruned it cannot answer, and the keeper refuses to start rather than scan from block 0:

could not find the factory's deployment block (…). Set startBlock in the config to the block the factory was deployed in, use an RPC that keeps historical state, or configure a subgraph

startBlock matters only for the first start without a subgraph (or after the state directory is emptied): later starts resume from the snapshot.

rpc

KeyTypeDefaultMeaning
httpstringrequiredthe JSON-RPC URL every read goes to. Usually holds a key: write ${RPC_HTTP_URL}
sendstringhttpwhere transactions are submitted: sequencer for the network’s sequencer endpoint (Arbitrum One only; on another network it is an error), or any URL
maxRequestsPerSecondnumber > 025the client’s rate limit on http
logChunkinteger > 02000blocks per eth_getLogs request

The node must support eth_call state overrides for rewards to be measured (What the Keeper Does). A log range it refuses (too many blocks or logs) is halved and asked for again, down to one block, and widened again after eight successes in a row; a lower logChunk avoids the refused requests. Rate-limit answers (HTTP 429), timeouts and connection failures are not refusals: the sync step fails and the next tick resumes after the last chunk applied.

subgraph

KeyTypeDefaultMeaning
urlstringdefaultdefault (the network preset’s subgraph; no preset has one yet, so this means none), none, or any deployment of Bazaar’s subgraph
apiKeystringnonesent as Authorization: Bearer <key>. A ${NAME} reference

The subgraph only finds accounts and orders for the first start: the keeper reads its _meta block, pages through open positions and live or bounty-holding orders pinned to that block, and tails the chain’s logs from the next block. Every decision is made from chain state. If the subgraph fails for any reason, the first start indexes from chain logs instead. The opportunities scan also uses it to find open termination claims, insurer votes and listings.

prices

Prices come from Pyth Pro: signed updates the keeper attaches to its calls and the pair’s oracle has verified on chain. Pyth Pro needs an access token, which you get from Pyth.

KeyTypeDefaultMeaning
endpointstringbazaarpyth (Pyth’s own Pyth Pro endpoints, with your own token), bazaar (Bazaar’s shared endpoint, which has no URLs yet: choosing it is an error), or the http(s) base URL of a Pyth Pro compatible service
tokenstringnonethe Pyth Pro access token, sent as Authorization: Bearer <token>. A ${NAME} reference
channelreal_time | fixed_rate@50ms | fixed_rate@200ms | fixed_rate@1000msfixed_rate@200msthe channel the keeper streams and polls
streamUrlslist of strings, at least onethe endpoint’sthe WebSocket stream endpoints, in place of the endpoint’s own; the keeper connects to every one
restUrlstringthe endpoint’sthe request API’s base URL (/v1/latest_price, /v1/price), in place of the endpoint’s own
symbolsUrlstringthe endpoint’sthe symbols registry’s base URL (/v1/symbols), in place of the endpoint’s own
headersmap of string{}extra request headers for the request API and the stream connections (a proxy’s, say); each value a ${NAME} reference, optionally after a scheme (Bearer ${NAME})
checkSignersbooleantrueuse only updates signed by a signer the deployment’s Pyth verifier trusts
allowInsecurebooleanfalsesend the token, the headers, and a user name, password or query a URL holds, over plain http/ws to an endpoint on another machine. Refused by default; an endpoint on this machine (exactly localhost, 127.0.0.0/8 or [::1]) never needs it
streambooleantruefollow the WebSocket price stream; when off, poll the request API for the latest price
pollIntervalnumber > 01.0seconds between polls when not streaming
streamIdleTimeoutSecondsnumber ≥ 010a stream that brings no new update for this long is dropped and reconnected; 0 waits forever
sendMarginSeconds0 ≤ number < 31.0a call carries a print only while it is this much younger than the pair’s 3 s limit, so it is still fresh when the transaction lands

The endpoint. pyth is three stream endpoints (wss://pyth-lazer-0.dourolabs.app/v1/stream, and -1 and -2), the request API at https://pyth-lazer.dourolabs.app and the symbols registry at https://pyth.dourolabs.app. A base URL names a service that answers the request API and the symbols registry at that URL and streams at its /v1/stream over ws or wss: http://127.0.0.1:8787 (where bazaar mock-prices listens) means the request API and the registry there and the stream at ws://127.0.0.1:8787/v1/stream. streamUrls, restUrl and symbolsUrl each replace one of the three, over a preset or a base URL. A URL that carries a key is a secret like any other: write it as ${NAME} (the secrets rules).

Plain http and ws. A token or a header sent over plain http or ws can be read by anyone on the path, so the keeper refuses to start when one would go that way to another machine: prices: the price request URL uses http://, which would send the price token and headers unencrypted to another machine: …. The same holds for a user name, a password or a query inside a URL, token or no token (prices: a price stream URL holds a user name, a password or a query and uses ws://, …); no message quotes the URL. Use https and wss. Set allowInsecure: true only for an endpoint on a network you trust end to end (a proxy beside the keeper, say). This machine is exactly localhost, an address in 127.0.0.0/8, or [::1]: a name under .localhost is resolved like any other name, so it counts as another machine. bazaar mock-prices on the same machine needs neither a token nor this.

A compatible service must serve POST /v1/latest_price, POST /v1/price (the one-second channel’s update for a past second, which terminate-scheduled settles with), GET /v1/symbols (feed symbols, for market labels; asked without the token) and the WebSocket stream.

The stream. Updates are judged against the chain’s clock, as their prices are: one stamped more than a minute ahead of it drops the endpoint that sent it. The keeper opens one subscription per watched market, for that market’s feeds together, plus one for ETH/USD (Pyth Pro feed 2, for pricing gas). The signed update a call attaches therefore carries exactly its pair’s feeds, both legs of a composite in one update, and costs one verification fee. The same subscriptions run on every stream endpoint and each update is taken once, from whichever endpoint brings it first; an endpoint that drops is reconnected while the others keep delivering. Three failures in a row mark prices unhealthy and switch a stream to polling for a minute. Each newer print wakes the liquidation and trigger planners. The stream runs on the ws package, on every supported Node version, bounded so an endpoint cannot make the keeper buffer more than it reads: no compression, no redirects, a 10 s handshake timeout, and a frame larger than 3 MiB ends the connection unread (the endpoint is reconnected like any that drops).

The channel. A faster channel shows a move sooner and brings more updates to read. A print is as old as its price, not as the update that carries it: a quiet feed’s updates carry its last price forward, and those read as stale. A feed Pyth does not serve on the chosen channel, or that the token does not cover, gets no print; the keeper warns once per feed and its markets get no price.

checkSigners. Once the deployment is read, the keeper asks its Pyth verifier whether it trusts the signer of each update (isValidSigner, each answer kept for a minute) and takes no price from an update signed by anyone else: a stream endpoint that sends one is dropped and reconnected while the others keep delivering, and a request API response that carries one is refused whole. The chain verifies every update a call carries either way; the ETH/USD print that prices gas reaches no contract, so this is the only check on it, and with it off the keeper warns at start (bazaar keeper doctor says so too). It costs one signature recovery per update taken, which adds up with many markets on a fast channel: turn it off, or pick a slower channel, if the keeper falls behind the stream.

wallets

A map from a name of your choosing to a wallet. Jobs use default unless they name another. Left out, the keeper is watch-only. Wallets & Secrets explains each type.

KeyTypeDefaultMeaning
typekeystore | privateKey | mnemonic | remoterequiredthe kind of wallet
pathstringnonekeystore: the file (~/ is your home directory). Required for keystore
passwordstringnonekeystore: its password, a ${NAME} reference; left out, asked for at start in a terminal
keystringnoneprivateKey: the key, a ${NAME} reference. Required for privateKey
phrasestringnonemnemonic: the phrase, a ${NAME} reference. Required for mnemonic
indexinteger ≥ 00mnemonic: the account, m/44'/60'/0'/0/{index}
urlstringnoneremote: the signer’s JSON-RPC URL. Required for remote
addressaddressnoneremote: the address it signs for. Required for remote

jobs

Each job is switched on by being present (liquidation: {} or with settings), and off by being left out, null, or enabled: false. Every job takes the common keys; most also have their own. The Jobs explains what each one does with them.

Common Keys

KeyTypeDefaultMeaning
enabledbooleantruefalse keeps the job’s settings but turns it off
marketsall | list of stringsallthe markets it runs on. all includes future listings. A list names markets by address, pair id, or label or description (case-insensitive)
excludelist of strings[]markets it never runs on, named the same ways
walletstringdefaultthe wallet it sends from; must be under wallets (when there are any)
dryRunbooleanfalseplan and simulate, never send (as start --dry-run does for every job)
minProfitUsdnumberprofit.minProfitUsdthis job’s profit rule
gasSafetyMultipliernumberprofit.gasSafetyMultiplierthis job’s profit rule
allowLossbooleanfalse (true for sweepExpired)also send calls that cost more than they earn, within the caps below
maxLossUsdPerCallnumber ≥ 00.05the most one losing call may lose
lossBudgetUsdPerDaynumber ≥ 01.0 (0.25 for sweepExpired)the most this job may lose in 24 hours; also the budget for its unpaid duties (finalize, health pokes)

Naming markets. An address names one market and must be one of the factory’s (0x… is not a market of this factory). A label such as BTC/USD names every market carrying it: labels are not unique, since a market relisted after a termination keeps its label and a composite is labelled after its base. Such a label covers all of them, each job acting only on those in a market state it runs in, and the keeper logs a warning naming their addresses. A name no market carries stops the start with no market called 'X'. Markets: …. bazaar keeper markets lists every market with its label and address.

liquidation

KeyTypeDefaultMeaning
watchBufferPctnumber ≥ 03.0positions within this many percent of their maintenance requirement count as at risk in status.json (the atRisk field); it does not change what is liquidated
maxAccountsPerCallinteger > 040accounts per liquidate call; more are split into several calls

triggers

KeyTypeDefaultMeaning
minBountyUsdnumber ≥ 00.01orders with a smaller bounty are not fired
includeUnbountiedbooleanfalsealso fire orders with no bounty at all (they pay nothing, so they send only with allowLoss)

unload

KeyTypeDefaultMeaning
maxStepsinteger, 1 to 3232the book walk’s step budget per unloadVault call
recheckSecondsnumber > 015how often a market holding inventory is simulated again when nothing has changed

adl

KeyTypeDefaultMeaning
maxWinnersPerSideinteger, 1 to 2525candidates per list in an executeAdl batch

refresh

KeyTypeDefaultMeaning
healthPokesbooleanfalsealso pay for unpaid refreshes that would open an ADL window or end a timed-out one. Turning it on is consent to pay for them
healthPokeIntervalSecondsnumber > 060how often each market holding inventory is checked for a health poke
healthPokeMaxCostUsdnumber ≥ 00.1the most one health poke may cost

terminalSettlement

KeyTypeDefaultMeaning
finalizebooleantruefinalize the termination once every position is settled or the 14-day escape opens. finalizeTermination pays nothing: true is consent to pay for it
finalizeMaxCostUsdnumber ≥ 00.5the most one finalize may cost
maxAccountsPerCallinteger > 0100positions settled per call; the next tick takes the rest

collect

KeyTypeDefaultMeaning
thresholdUsdnumber ≥ 05claim a market once a wallet’s balance there reaches this, and covers the claim’s gas × gasSafetyMultiplier
maxAgeDaysnumber > 07a balance first seen longer ago than this is claimed whenever it covers its gas, whatever its size
forwardToaddressnonemove each wallet’s USDC here once it holds forwardMinUsd
forwardMinUsdnumber ≥ 050the USDC balance at which a wallet forwards
onShutdownbooleantrueon a clean stop, claim every balance that covers its gas
intervalSecondsnumber > 0600how often balances are read (sooner, after 5 s, when the logs show a wallet’s balance changed)

sweepExpired

KeyTypeDefaultMeaning
allowLossbooleantruesweepExpired pays nothing, so every call is a loss: the job only runs with this on
lossBudgetUsdPerDaynumber ≥ 00.25the job’s daily budget
minBatchinteger > 010expired orders to wait for before sweeping (finished orders holding a bounty are swept at once)
maxOrdersPerCallinteger > 0100orders per call
includeBountyRefundsbooleantruealso sweep finished orders still holding a bounty, which returns it to the owner

profit

KeyTypeDefaultMeaning
minProfitUsdnumber0.001a paid call is sent when reward − gas ≥ this…
gasSafetyMultipliernumber ≥ 11.25…and reward ≥ gas × this

Each job can override both. Budgets & Losing Calls.

gas

KeyTypeDefaultMeaning
maxFeeGweinumbernonea hard ceiling on the fee per gas: nothing is sent while the base fee is above it, and maxFeePerGas is capped at it
backgroundMaxFeeGweinumber or null0.1the ceiling for background work (collect, forwarding, sweeps, finalize); null removes it
feeMultipliernumber ≥ 12.0maxFeePerGas = base fee × this (priority fee is always 0)
gasLimitMultipliernumber ≥ 11.25gas limit = estimate × this + 10,000
ethUsdFallbacknumber > 03000prices gas when the endpoint serves no fresh, plausible ETH/USD print
ethUsdFloornumber > 01000gas is never priced below this, so a bad feed cannot make calls look free
ethUsdMaxAgeSecondsnumber > 060an older ETH/USD print is not used; ethUsdFallback is

budget

KeyTypeDefaultMeaning
maxSpendUsdPerDaynumber ≥ 010all gas, every job, in the last 24 hours, sends in flight included
maxSpendEthPerDaynumber ≥ 0maxSpendUsdPerDay / gas.ethUsdFloor (0.01 ETH with the defaults)the same cap counted in wei, gas and update fees together, so no price feed can lift it
maxLossUsdPerDaynumber ≥ 02every loss-taking and unpaid call together
minEthBalancenumber ≥ 00.002ETH; while any wallet holds less, all sending stops

Budgets & Losing Calls explains how they combine.

safety

KeyTypeDefaultMeaning
pauseAfterRevertsinteger > 03this many on-chain reverts in a row pause a job on a market…
pauseMinutesnumber > 015…for this long, with a jobPaused alert

A revert on chain means the state changed between the simulation and the block (usually another keeper got there first). A success resets the count.

alerts

KeyTypeDefaultMeaning
telegram{ botToken, chatId }nonebotToken a ${NAME} reference; chatId a string
discord{ webhook }nonethe webhook URL, a ${NAME} reference
webhook{ url }noneany URL; receives POST {"kind", "message", "ts"} as JSON
notifylist[lowBalance, jobPaused, endpointDown, opportunity, dailySummary]which kinds are sent; action (one message per sent call) is the sixth kind

Running in Production describes each kind.

state

KeyTypeDefaultMeaning
dirstring~/.bazaarwhere the keeper keeps its snapshot, earnings ledger and status file, under keeper/<chainId>-<first 8 hex digits of the factory>/

So one state directory can hold several keepers (different networks or factories) side by side. init sets it to state/ beside the config.

log

KeyTypeDefaultMeaning
leveldebug | info | warning | errorinfothe lowest level shown
formatpretty | jsonprettypretty: 12:00:01 INFO message; json: one object per line, {"ts", "level", "logger", "msg", "exc"?}

engine

KeyTypeDefaultMeaning
tickSecondsnumber > 00.5how often the keeper reads the head and new logs and lets every job plan (liquidation and triggers also plan on each new print)
simCooldownSecondsnumber ≥ 02.0how long a candidate the chain refused waits before it is simulated again
idleRefreshSecondsnumber > 05.0how often a market’s state is re-read when no event marked it changed
snapshotSecondsnumber > 060how often the index is saved

A Complete Example

Every section, with the defaults written out where they are worth seeing. Secrets come from .env beside the file.

# ---- Where ----
network: arbitrum-one
factory: "0x0000000000000000000000000000000000000000"  # quoted: YAML reads bare 0x… as a number
startBlock: 123456789            # the factory's deployment block; leave out to have it found on chain

rpc:
  http: ${RPC_HTTP_URL}          # the provider's URL holds its key: it lives in .env
  send: sequencer                # Arbitrum One: submit straight to the sequencer
  maxRequestsPerSecond: 25
  logChunk: 2000                 # blocks per eth_getLogs; refused ranges are halved automatically

subgraph:
  url: ${SUBGRAPH_URL:-none}     # a Bazaar subgraph if SUBGRAPH_URL is set, else index from logs

prices:
  endpoint: pyth                 # Pyth Pro; or ${PRICE_ENDPOINT_URL} for another Pyth Pro compatible service
  token: ${PYTH_PRO_TOKEN}       # your Pyth Pro access token
  channel: fixed_rate@200ms
  checkSigners: true             # only updates signed by a signer the deployment's Pyth verifier trusts
  stream: true
  streamIdleTimeoutSeconds: 10
  sendMarginSeconds: 1.0

# ---- Who ----
wallets:
  default:                       # collect, refresh, unload, settlement, manual operations
    type: keystore
    path: /srv/keeper/keys/default.json
    password: ${KEEPER_KEYSTORE_PASSWORD}
  fast:                          # its own nonce queue for the jobs that race
    type: keystore
    path: /srv/keeper/keys/fast.json
    password: ${KEEPER_FAST_PASSWORD}

# ---- What ----
jobs:
  liquidation:
    wallet: fast
    markets: all
    allowLoss: true              # liquidate even at a small loss: it keeps the market solvent
    maxLossUsdPerCall: 0.10
    lossBudgetUsdPerDay: 2
  triggers:
    wallet: fast
    minBountyUsd: 0.02
  unload: {}                     # {} enables a job with its defaults; an empty key would disable it
  adl: {}
  refresh:
    exclude: ["0x0000000000000000000000000000000000000001"]   # a market's address: never refresh it
    healthPokes: true            # pay for refreshes that open or end an ADL window
    healthPokeMaxCostUsd: 0.10
  terminalSettlement:
    finalize: true               # unpaid, but it releases everyone's payout
    finalizeMaxCostUsd: 0.5
  collect:
    thresholdUsd: 10
    maxAgeDays: 7
    forwardTo: "0x0000000000000000000000000000000000000002"   # a cold wallet
    forwardMinUsd: 100
  sweepExpired:                  # unpaid public service, within its daily budget
    lossBudgetUsdPerDay: 0.25
    minBatch: 10

# ---- How much ----
profit:
  minProfitUsd: 0.001
  gasSafetyMultiplier: 1.25
gas:
  maxFeeGwei: 1                  # never pay more than 1 gwei per gas
  backgroundMaxFeeGwei: 0.1      # collect, sweeps, finalize and forwarding wait for a quiet chain
  feeMultiplier: 2.0
  ethUsdFallback: 3000
  ethUsdFloor: 1000
  ethUsdMaxAgeSeconds: 60
budget:
  maxSpendUsdPerDay: 20
  maxSpendEthPerDay: 0.02        # the same cap in ETH, which no price feed can lift
  maxLossUsdPerDay: 5
  minEthBalance: 0.005
safety:
  pauseAfterReverts: 3
  pauseMinutes: 15

# ---- Around it ----
alerts:
  telegram:
    botToken: ${TG_BOT_TOKEN}
    chatId: "123456789"
  notify: [lowBalance, jobPaused, endpointDown, opportunity, dailySummary]
state:
  dir: /srv/keeper/state
log:
  level: info
  format: json                   # one JSON object per line, for a log collector
engine:
  tickSeconds: 0.5
  simCooldownSeconds: 2.0
  idleRefreshSeconds: 5.0
  snapshotSeconds: 60