Kerf is a first release and may still have small bugs. Our team is building it around the clock - follow updates and new features onEarly release - small bugs possible. Updates onx.com/UseKerf
Kerf
Chapter 10 · Indexer, API and bots

Indexer, API and bots

Events into tables, the routes the app reads, and the keeper, hedger and graduator ticks.

Three processes sit between the chain and the page: an indexer that folds Uniswap and Kerf logs into tables, an API that turns those tables, the chain and three market-data feeds into the eleven routes the web reads, and three bots inside that API process that harvest, hedge and graduate. None of them holds a user's funds and none of them can move one: the web writes to the contracts directly, the API's only writes are a signed thesis, a signed perp envelope and a feedback form, and the bots spend their own key's gas for a bounty the contracts pay in the open.

The indexer

apps/indexer is a Ponder 0.16 app. It reads every log it is subscribed to, runs one handler per event, writes Postgres (or the bundled PGlite under .ponder/ when DATABASE_URL is empty) and serves the result as GraphQL on :42070. The API is the only consumer - every document it sends lives in apps/api/src/indexer/queries.ts - and the web never queries the indexer, so its schema can change without a browser noticing.

Indexer pipelineLeft: the logs Ponder subscribes to, by source. Middle: the tables they fold into (ponder.schema.ts). The API is the only reader of the GraphQL endpoint; the web never queries it. Kerf sources start at the manifest deployBlock - with no manifest they end at block 0 and index nothing.
Uniswap · from PONDER_POOLS_START_BLOCKKerf contracts · from deployBlocktables · ponder.schema.tsuptimefeesearnedGraphQLqueries.tsJSONv3 PoolCreated · Initializev3 Swap · Mint · BurnNPM Transfer · Increase · DecreaseNPM Collectv4 Initialize · Swap · ModifyLiquidityRangeOrders Placed · Filled · CancelledPositionKeeper Enrolled · UnenrolledPositionKeeper Harvested · RebalancedVaultFactory VaultCreatedvault Deposit · Withdrawvault Harvest · Rebalancevault PositionOpened · HedgeFundedvault HedgeReported · Paused · Resumedvault SleeveReported · SettledLaunchPipeline Tracked · GraduatedPreMarketPerp MarketCreated · OpenedPreMarketPerp Closed · LiquidatedPreMarketPerp Funding · SocialisedFeeRouter TakenReferralRegistry Registered · BoundpriceCache'WETH' · token → USD, on every swappool · poolHourStattvlUsd · apr24hPct · establishedposition · rangeUptimeentryPrice · ilVsHodlPct · inRangepositionEvent · feesCollectedmint · increase · decrease · collectrangeOrderopen · filled · cancelled · bountykeeperEnrollment · keeperActionpolicy flattened · succeededByvault · vaultShare · vaultEventharvest rows drive the vault APRgraduationTracked opens · Graduated fills inperpMarket · perpPosition+ perpGlobal · perpEventreferralcode · referrer · earned{token}GraphQL :42070/graphql · /healthzapps/apithe only consumerapps/webJSON from the APITwo eth_calls per rowThe NPM mint and RangeOrders.Placedread positions() / orders() at theevent block, so the RPC must holdstate that far back.

Sources and start blocks

There are two families of source, and they start at different blocks for different reasons.

SourceContractStarts atWhat it is
UniswapV3Factorythe v3 factoryPONDER_POOLS_START_BLOCKPoolCreated only; it is the parent of the pool factory source below
UniswapV3Poolevery pool the factory ever createdPONDER_POOLS_START_BLOCKa Ponder factory source over PoolCreated.pool: Initialize, Swap, Mint, Burn
NonfungiblePositionManagerthe v3 NPMPONDER_POOLS_START_BLOCKTransfer, IncreaseLiquidity, DecreaseLiquidity, Collect
PoolManagerthe v4 singletonPONDER_POOLS_START_BLOCKInitialize, Swap, ModifyLiquidity - pool level only
FeeRouter, ReferralRegistry, RangeOrders, PositionKeeper, VaultFactory, LaunchPipeline, PreMarketPerpthe manifest addressesdeployBlockone source each, real compiler-generated ABIs from @kerf/abi
KerfVaultevery vault clonedeployBlockone factory source over VaultFactory.VaultCreated.vault, using the union of the three implementation ABIs

The Uniswap sources default to deployBlock − 200 000 (POOLS_BACKFILL_BLOCKS, about thirty days on Robinhood Chain) when PONDER_POOLS_START_BLOCK is unset, because indexing v3 from genesis takes hours; production always sets it explicitly. The Kerf sources start at the manifest's deployBlock because nothing of theirs exists before it. With no PONDER_MANIFEST at all they fall back to the zero address and an endBlock of 0, so they can never fetch a log - they are still declared, which keeps the "Source:Event" types stable whether or not a manifest is present.

KerfVault is deliberately one source rather than three: LPVault, HedgedLPVault and StructuredVault share the handlers and a vault simply never emits the events its kind does not have. Three sources over the same factory parameter would index every vault three times.

The public RPC returns 429 above roughly 15 requests a second, so the transport is wrapped in Ponder's rateLimit at PONDER_RPC_MAX_RPS (default 15); 0 disables the throttle for a local node.

Which event feeds which table

Handlers are thin; anything with an edge case is a pure function in src/logic/ with tests in test/. Tests must not live next to the modules they cover, because Ponder executes every src/**/*.ts except src/api/** as an indexing file.

EventHandlerWrites
PoolCreated, v4 Initializepools.ts, v4.tspool row; v4 rows key on the 32-byte pool id with version: 'v4' and native ETH mapped to WETH for pricing
Swap (v3 and v4)common.ts applySwappriceCache, the hour's poolHourStat, pool.tick/liquidity/swapCount; on an hour boundary tvlUsd, apr24hPct, established
Mint, Burn, ModifyLiquidityapplyLiquidityDelta, refreshPoolTvlpool.liquidity (only when the range straddles the tick, as Uniswap does) and tvlUsd
NPM Transfer from zeropositions.tsopens the position and rangeUptime rows, reading positions(tokenId) at the same block for ticks and pool; a transfer to zero sets closedAt
IncreaseLiquidity, DecreaseLiquiditypositions.tsliquidity, entryPrice and entryAmount0/1 on the first increase, withdrawn0/1 on a decrease, a positionEvent row
NPM Collectpositions.tsfeesCollected row, position.feesCollected0/1, a positionEvent
Placed, Filled, Cancelledkerf/rangeOrders.tsrangeOrder; Placed reads orders(orderId) for the pool and liquidity the event does not carry
Enrolled, Unenrolled, Harvested, Rebalancedkerf/keeper.tskeeperEnrollment (the flattened policy plus a JSON copy) and one keeperAction per event
VaultCreatedkerf/vaults.tsvault with kind 0/1/2 spelled out as kindName lp, hedged or structured
Deposit, Withdrawkerf/vaults.tsvaultShare per holder, vault.totalShares and netAssets, a vaultEvent
Harvest, Rebalance, PositionOpenedkerf/vaults.tslifetime harvest totals, ticks and positionTokenId on the vault, a vaultEvent whose amount0/1 are the harvested fees
HedgeFunded, HedgeReported, HedgePaused, HedgeResumed, SleeveReported, Settledkerf/vaults.tsthe hedge report columns, paused and pauseReason, settled
Tracked, Graduatedkerf/launch.tsgraduation: Tracked opens the row, Graduated fills in vault, pool, graduatedAt
MarketCreated, Opened, Closed, Liquidated, Fundingkerf/perp.tsperpMarket and perpPosition
InsuranceChanged, Socialised, DeferredClaimedkerf/perp.tsthe single perpGlobal row and a perpEvent per socialisation or claim
Takenkerf/feeRouter.tsreferral.earned for the referrer, as a per-token running total
Registered, Boundkerf/referral.tsreferral.code, referrer, referredCount

Three conventions the API relies on: every address and hash is stored lowercase (norm(), because viem checksums decoded address arguments but leaves log.address lowercase and the two must join); token amounts, liquidity and timestamps are bigint and cross GraphQL as strings; USD figures and percentages are real and whole percents, for display and ranking only, never a transaction argument.

Derived fields

Some columns are computed rather than copied, and each has a rule worth knowing.

Prices. priceCache holds USDG at $1 by definition (never stored), WETH from the latest WETH/USDG swap under the literal id 'WETH', and every other token as pool price × the quote's USD price, written on every swap in a pool whose other side is USDG or WETH. Volume is measured on the quote side of the pool, never on the risk asset, so a manipulated pool price cannot inflate it. A pool with no quote side contributes zero USD of volume. Only the canonical tokens have known decimals; anything else is assumed to be 18-decimal, which is why tvlUsd is a display field.

Pool TVL. There is no cheap honest answer, so the indexer uses two methods and says which. While a pool has at most TVL_EXACT_POSITION_LIMIT (200) open positions, tvlUsd is the exact sum of those positions' current value. Above that it values the active liquidity as if it were one position spanning TVL_APPROX_WINDOW_TICKS (953, about ±10 %) around the current tick, which understates a pool spread wider and overstates one tighter. TVL is recomputed on Mint/Burn and on the first swap of each hour, not on every swap, because a full position scan per swap buys no better answer. v4 pools always use the approximation because their positions are not tracked.

24 h stats, APR and Established. poolHourStat is an hourly rollup; the trailing window is a sum over the last 24 buckets, refreshed when a swap crosses an hour boundary. apr24hPct is @kerf/core's feeAprFromPool over that window, and established is isEstablished: age ≥ 3 days, market cap above $1M and ≥ 100 trades in 24 h. A token with no supply source has marketCapUsd null and is never Established.

Range uptime. A v3 position earns only while the tick is inside the half-open [tickLower, tickUpper). Uptime is accrued on every pool swap for the interval since the last accrual, credited when the tick the pool held entering the interval was in range - the previous tick, not the new one, because the position earned at the old price and only moves to the new one at the interval's end. The clock never moves backwards, so a reorg cannot subtract uptime. rangeUptime mirrors position.inRangeSeconds as its own row so /portfolio can read it without loading whole positions.

IL vs HODL. ilVsHodlPct compares the entry amounts at entryPrice (the token1-per-token0 ratio at the first IncreaseLiquidity) with the live position plus everything already withdrawn. Collected fees are excluded on purpose: IL measures the principal, and fees are the compensation for it.

Five things that look like bugs

  • A rebalance changes an enrollment's primary key. PositionKeeper.Rebalanced mints a new NFT; moveEnrollment retires the old keeperEnrollment row with succeededBy and opens a new one under the new token id with succeeds pointing back and the lifetime counters carried forward.
  • Liquidated fires before Closed in the same transaction, so the Closed handler must not stamp closed over a row already flagged liquidated (perpStatusAfterClose).
  • vault.paused is the flag, not the whole answer. It mirrors the contract's _hedgePaused, which HedgePaused/HedgeResumed report. HedgedLPVault.paused() also returns true when the last report has gone stale, and staleness needs no transaction and so emits no log; compare hedgeAsOf against the vault's maxHedgeAge or call the vault.
  • Share transfers are not indexed. vaultShare.shares is clamped at zero; a holder who was sent shares has no record of them arriving. The API values a holding as shares / totalShares, so a transferred share is mis-attributed, never double-counted.
  • referral.earned values are decimal strings, because JSON has no bigint and these are raw token amounts that routinely exceed 2^53. Taken with a zero referrer is skipped (there is no row to credit) and Claimed is not indexed at all (it moves an earned balance, it does not change how much was earned).

The three fork limits

Anyone who runs the indexer against an anvil fork of Robinhood Chain meets three properties of forking a live chain that look exactly like bugs.

  1. PONDER_POOLS_START_BLOCK must be at or above the fork block. The public RPC keeps no archive state - it answers metadata is not found for a state read more than a few hundred blocks old - and the handlers eth_call positions() and orders() at each event's block. The documented default of deployBlock − 200 000 produces a wall of failed calls and a 22-hour backfill estimate; the smoke script pins the start block to the manifest's deployBlock.
  2. An idle fork goes read-only. anvil fetches storage lazily at the fork block, and once the upstream has pruned it every slot the fork has not already cached is unreachable - measured at about 40 minutes. Deploy and write immediately after starting anvil.
  3. The indexer can never see a pre-existing pool. It discovers pools from PoolCreated at or after the start block, and every Uniswap pool on the chain predates any fork (WETH/USDG 0.05 % was created at block 169 464). The pool table is empty on a fork unless something creates a pool on it, and with no WETH/USDG pool to seed priceCache, a WETH-quoted pool prices at zero. Radar's pool leg is served from a chain snapshot instead - see the API below.

Configuration and health

apps/indexer environment
ParameterRangeDefaultMeaning
PONDER_CHAIN_ID4663 or 31337466331337 only for a plain anvil; a fork of Robinhood Chain keeps 4663
PONDER_RPC_URLURLthe public Robinhood RPCthe owner's Alchemy endpoint has archive state; the public one does not
PONDER_RPC_MAX_RPS≥ 015the public endpoint 429s above this; 0 disables the throttle for a local node
PONDER_MANIFESTpath-contracts/deployments/<network>.json; without it only Uniswap is indexed
PONDER_POOLS_START_BLOCKblockdeployBlock − 200 000first block for the Uniswap sources; on a fork it must be the deploy block
DATABASE_URLURLempty = PGlite under .ponder/production points at Postgres
DATABASE_SCHEMAnamekerf_indexerthe indexer needs its own schema, separate from the API's

Ponder reserves /health, /ready, /status and /metrics for itself; registering our own /health is a build error. src/api/index.ts adds /healthz for a parseable { ok: true }, serves the GraphQL endpoint at / and /graphql, and the read-only client protocol at /sql/*. Gate traffic on /ready, which only turns 200 once the historical backfill is complete - /health is 200 as soon as the process is up.

apps/api/src/indexer/queries.schema.test.ts is the guard between the two packages: it reads ponder.schema.ts, collects every column an onchainTable declares, and fails when any leaf field in a GraphQL document of queries.ts is not one of them. It is a regex, not a parser - it does not know which table a field belongs to - but that is enough to catch a query asking for a column the indexer never stored, which is the class of bug that used to reach the browser as an empty card.

The API

apps/api is one Fastify process on PORT 8790. loadConfig parses the environment once into a Config and buildApp(config, deps) takes every dependency injected, so the routes cannot tell a test's fakes from server.ts's real wiring, and no module reads process.env on its own. Everything optional degrades rather than failing: no DATABASE_URL means in-memory repositories (no database), no reachable indexer means routes answer with empty data and /health reports indexer: 'down', and VENUE defaults to sim. Two things do not degrade: NODE_ENV=production without CORS_ORIGIN is a ConfigError before the socket opens, and TRUST_PROXY is off unless set.

API routes and their sourcesDashed arrows are reads; the solid stream is the response. Every route maps its domain model to the shape apps/web/lib/api.ts types before it answers - wire.ts is the boundary, and web.types.test.ts fails on a rename from either side.
the wire contractno pool rowsnoncesguardsJSONIndexer · GraphQLIndexerClient · queries.tsChain snapshotpoolSnapshot + poolStats (24 h)Market dataLighter → Hyperliquid → YahooPostgres · in-memorytheses · points · nonces · simPerp venueSimVenue | LighterVenue → signerChain reader · viemMulticall3-batched eth_callGET /radarcache 15 s · buildRadar → toRadarWireRowGET /markets · GET /candlescache 15 s / 60 s · toMarketRowGET /portfolio/:addressno cache · computePortfolio → wireGET /vaultscache 30 s · [] without a manifestGET /launch?address=base cached 30 s · positions freshPOST · GET /theses · leaderboardEIP-712 · /:id/mirror returns calldataGET /referral/:addressearned per token → USD at the markGET /points/:addresscomputePoints · POST /points/recomputePOST /perp/* · GET /perp/*EIP-712 envelope · nonce · 300 s skewPOST /contact5 / min / IP · 2 000 charactersGET /health{ok, venue, indexer} · probe cached 15 sroutes/wire.tsdomain model → the web shaperoutes/types/web.tsmirror of apps/web/lib/api.tsweb.types.test.tsfails on a rename either sideapps/web/lib/api.tstyped fetchers · React Query

Routes

The list below is everything the API serves; every route apps/web/lib/api.ts types is in it, plus /perp/bind, /perp/positions/:address and /points/recompute, which the web does not call.

RouteAnswers fromCacheNotes
GET /radarindexer pools, or the chain snapshot when there are none, joined to market data15 s (RADAR_CACHE_MS)bare RadarRow[]; ?filter= (all, crypto, rwa or established) for other callers
GET /marketsmarket data, plus hourly closes for spark15 s (CACHE_MARKETS_MS)one row per symbol in SYMBOLS, mark: null when no source answered
GET /candles?market=&tf=&limit=Lighter → Hyperliquid → Yahoo60 s (CACHE_CANDLES_MS)tf in 1m 5m 15m 1h 4h 1d, limit 1 - 1000, default 300
GET /portfolio/:addressindexer positions, vault shares, range orders, enrollments; venue positions and account; chain readPoolMeta for pools the indexer lacksnonetoPortfolioWire
GET /vaultsmanifest vaults plus graduated ones; chain reads of each vault; indexer harvest log; market data30 s (VAULTS_CACHE_MS)[] with no manifest
GET /launch?address=indexer pools and graduations; chain firstSeen, minLiquidity, perp markets, insurance fund; the caller's open perp positionsbase 30 s (LAUNCH_CACHE_MS), positions fresh{tracked: [], markets: [], positions: []} with no manifest
POST /thesesverifies an EIP-712 signature, stores-201 with the thesis
GET /theses?market=&author=&limit=&cursor=Postgres-bare Thesis[]; the next cursor rides in the x-next-cursor header
GET /theses/leaderboardPostgres plus the indexer's realised fees and PnL per author-rank is position, 1-based
POST /theses/:id/mirrorthe thesis and, for a v3 ref, the indexer position-returns calldata or an order; never executes
GET /referral/:addressindexer referral row, priced with market marks-earningsUsd is one dollar figure
GET /points/:addressPostgres points events and theses-computePoints on the fly
POST /points/recomputethe same, for every address-x-admin-secret, constant-time compared
POST /perp/onboard, /bind, /order, /cancel, /withdraw-intentthe venue-every one behind the EIP-712 envelope
GET /perp/account/:address, /positions/:address, /orders/:addressthe venue-unauthenticated reads
POST /contactPostgres feedback-5 per minute per IP, 2 000 characters
GET /healtha 1 s probe of the indexer's /health, cached 15 s15 sbody is exactly {ok, venue, indexer} and echoes no config

Every error is {error, code}: bad_request 400, unauthorized 401, forbidden 403, not_found 404, conflict 409, rate_limited 429, unavailable 503, and a route-specific code where one helps (invalid_address, stale_ts, nonce_used, duplicate_thesis, manifest_missing).

The wire contract

The web's wire shape is the API's contract, and the API adapts to it. apps/web/lib/api.ts types every response; apps/api/src/routes/types/web.ts is a mirror of those interfaces, and web.types.test.ts reads both files and fails when a field is renamed, added or removed on either side. The API's domain models keep their own conventions - whole percents, Usd and Pct suffixes, indexer row names, {items, nextCursor} paging - and map on the way out: toRadarWireRow in radar.ts, toPortfolioWire in portfolio.ts, and routes/wire.ts for everything else. Two conversions recur and are easy to get backwards: the API's percent fields (funding1h, change24hPct) are whole percents and the web takes fractions, and a candle's t is unix milliseconds while the wire's time is unix seconds for lightweight-charts.

The guard catches renames, not semantics. Three places where the two sides agree on names and not yet on meaning: the web's api.perp.* fetchers do not build the auth envelope the routes now require, so the live perp path only works with VENUE=sim until they do; the web types the mirror response as MirrorPlan while the API answers { thesis, action }; and the web posts {email, message} to /contact where the route reads name, concern and an optional handle. All three are on the launch list.

Radar

buildRadar is a pure function over two inputs: indexer pools with 24 h stats and market stats from the feeds. A pool names a row through the non-numeraire side of its pair (USDG wins over WETH as the quote; WETH/USDG names the ETH row), joined to a Lighter market through @kerf/abi's TOKENS. A freshly launched token with no perp and no entry in TOKENS still gets a row from the ERC-20 symbol the indexer resolved. Assets Lighter lists but no pool trades get hasPool: false, and vice versa - the table is the union of the two venues, not the intersection.

Among a symbol's pools the best one is the highest fee APR, but only pools holding at least BEST_POOL_TVL_SHARE (1 %) of the symbol's total pool TVL compete, because a dust pool with a few swaps posts an absurd fee-to-TVL ratio and the trade page resolves the exact pool the row names. established is isEstablished on the best pool. The wire's basisSignal chip follows four rules: both legs present gives lp-and-short when the carry clears the watch bar and neutral otherwise; perp only gives long-perp; pool only gives neutral for an Established asset and avoid for an unproven one; neither gives avoid. Rows sort by pool TVL, then symbol.

When the indexer has no pool rows - it is down, or it is running against the public RPC and cannot see the pools that predate its start block - the route reads the pools straight from the chain. chain/poolSnapshot.ts asks the v3 factory for every (asset, USDG) and (asset, WETH) pair in TOKENS on each of the four fee tiers, and each pool's tick, liquidity and balances, in Multicall3 batches. chain/poolStats.ts adds the half a single block cannot answer: a rolling, incremental ring of hourly buckets built from the pool's own Swap logs, so 24 h volume, fees, trade count, the pool's age and the asset's market cap are real once a pool has enough coverage. Until then the row carries statsKnown: false and Radar reports lpApr: null and established: false rather than a fake 0 %. server.ts warms the snapshot at start and every 60 s so no request waits on it.

Market data

MarketData is one cached, fallback-chained view of every symbol. Lighter REST is the source of truth - market stats from /api/v1/orderBookDetails, the current funding rate from /api/v1/funding-rates (one row per reference exchange; only exchange: "lighter" is Lighter's own) - with the WebSocket stream filling gaps between polls. A crypto symbol with no Lighter mark falls through to Hyperliquid; a stock, index or commodity falls through to Yahoo. A symbol nothing answers for is still returned with mark: null and source: 'none', so Radar and the market list never silently lose a row. Funding and open interest exist only on Lighter; a fallback row keeps them null. Candles follow the same order, and Yahoo's 4 h bar is folded from its 60-minute bars because it has none of its own. A thundering herd collapses into one upstream round trip.

Portfolio

computePortfolio is pure; the route fetches its inputs in parallel and hands them over. The indexer supplies positions, vault shares, range orders and enrollments; the venue supplies perp positions and the account; pools come from the indexer for symbols and the current tick, and any pool the indexer has no row for (created before its start block) is read from the chain with readPoolMeta so the in-range flag still works. The USD figures the indexer does not store - valueUsd, costBasisUsd, feesEarnedUsd, amountInUsd - are computed in IndexerClient from the raw amounts plus current marks, which makes every one of them a live-price approximation: cost basis in particular uses the entry ratio against today's price of token1, because there is no historical price archive to do better.

Vaults and launch

Neither route is in the design spec's route list; both are read-only conveniences the web already has fixtures for, and every write on /yield and /launch goes straight to the contracts, so a stale or empty answer degrades the page to its fixtures rather than blocking anything. GET /vaults returns one card per manifest vault plus one per graduated token (named Kerf <symbol> Vault from the token's ERC-20 metadata), with apr24h/apr7d computed by computeVaultApr from the vault's own harvest log - null when there is no harvest in the window, never a misleading 0 %. feeYield is the one field the route never fills: it would need the sleeve's harvest history back to the note's inception. GET /launch selects tracked tokens from the indexer's pools and graduations (at most TRACKED_CAP, 50), then reads firstSeen, minLiquidity, each perp market and the insurance fund from the chain; the caller's open pre-market positions are the one address-dependent part and are read fresh every time.

Theses

A thesis is a signed claim about a position. POST /theses verifies an EIP-712 signature under the domain { name: 'Kerf', version: '1', chainId } over Thesis { positionRef, side, text, ts } - the market is derived or supplied metadata and is not signed, because what the author commits to is a position and a claim. positionRef is v3:<tokenId>, perp:<venue>:<market>:<id> or vault:<address>; side is long, short or lp; text is at most THESIS_MAX_TEXT (500) characters; ts must be within THESIS_TS_SKEW_SECONDS (300) of the server clock. The signature is good for one post: ts is part of the message, so (author, ts) identifies it, and a repeat is 409 duplicate_thesis - enforced under a race by the theses_replay_unique index. The leaderboard ranks authors by realised fees plus realised PnL across their indexed positions.

POST /theses/:id/mirror never executes anything. It returns the calldata (or the venue order) the client signs and sends itself: for a v3 ref, zapMint on the manifest's zapV3 with the author's pair, fee and ticks, amountIn 0, maxSlippageBps MIRROR_SLIPPAGE_BPS (100), a deadline MIRROR_DEADLINE_SECONDS (20 minutes) out and the numeraire as tokenIn; for a vault ref, deposit(0, address); for a perp ref, a market OrderRequest with size 0. Amounts are left at zero for the client to fill in: the author's size is their business, the mirrorer's is theirs.

Perp proxy and its authentication

Every /perp/* route is a thin forward to the configured venue. VENUE=sim is SimVenue, an in-memory book priced off the real marks: every new account is credited SIM_STARTING_EQUITY (10 000 USDG), market orders fill SIM_SLIPPAGE_BPS (5) away from the mark in the taker's disfavour, limit orders rest until the mark crosses them, maintenance margin is half of initial and funding accrues hourly from each market's funding1h. VENUE=lighter is LighterVenue, an HTTP client for services/signer (SIGNER_URL, shared SIGNER_SECRET in x-signer-secret) that forwards /onboard, /bind, /order, /cancel, /account/:address, /positions/:address, /orders/:address and /withdraw-intent. The API never holds an order key, and there is deliberately no withdraw route: /perp/withdraw-intent returns the payload the user's wallet signs, and on the sim venue answers 503 sim_no_withdraw. The rest of that trust model is in Perps via Lighter.

Every write body carries an envelope next to the route's own fields: auth: { nonce, ts, signature }, signed with signTypedData over PerpAction { action, account, payloadHash, nonce, ts } under the same domain the theses use. payloadHash is the keccak256 of the canonical JSON of the body without auth - keys sorted recursively, no whitespace, undefined dropped - so the wallet signs the exact order it is sending and a signature from one body cannot be attached to another. action is fixed per route (onboard, bind, order, cancel, withdraw-intent); the client does not choose it. requirePerpAuth checks in this order and for this reason: a well-formed address (400), a well-formed envelope (401 unauthorized), |now − ts| ≤ PERP_AUTH_TS_SKEW_SECONDS (300, else 401 stale_ts), the signature recovers to address over this body (401), and finally that (address, nonce) was never seen (409 nonce_used). The nonce is consumed last, only for an envelope that verified, so a forged request can never burn a nonce the real signer is about to use. A nonce row outlives the window by NONCE_EXPIRY_SLACK_SECONDS (60) and is then safe to drop, because after that the envelope is stale_ts before the nonce is ever read. The pure half - the message and the canonical hash - is @kerf/core's perpAuth.ts, which the web imports too, so neither side can drift.

Referrals and points

GET /referral/:address returns the code the address registered, the referrer it is bound to, and earningsUsd: the indexer's per-token earned totals priced with the same market marks every other route uses, USDG pinned at 1, a token nothing prices contributing nothing rather than its raw amount. referredCount is always 0 today - the route passes three arguments where the mapper takes four - and is on the launch list.

Points are a ledger, not a token: POINTS_PER_USD_VOLUME 1, POINTS_PER_USD_FEES 5, and volume counts THESIS_MULTIPLIER (2×) while the address has a thesis less than THESIS_LIVE_SECONDS (7 days) old. They are always recomputed from points_events rather than incremented in place, so a replay is idempotent; POST /points/recompute behind ADMIN_SECRET rewrites every total. Nothing in the API writes a points event yet - addEvent is called only from tests - so every live total is zero until the ingestion job on the launch list exists. The money side of both is in Fees & referrals.

Rate limits and hardening

Every POST, PUT, PATCH and DELETE shares one sliding-window budget of RATE_LIMIT_WRITES_PER_MIN (30) per (route group, client IP), where the route group is the first segment of the matched route pattern - /perp/order and /perp/cancel share /perp, and a client cannot mint fresh budget by varying :id. The hook runs onRequest, before the body is parsed, and a refusal is 429 with a Retry-After in whole seconds. Reads are never limited. /contact keeps its own stricter limiter on top: CONTACT_LIMIT 5 per CONTACT_WINDOW_MS (60 s) per IP. The limiter is in-process, so two replicas each grant the full budget; a multi-replica deployment needs it backed by Postgres or Redis. TRUST_PROXY decides what request.ip is and is off by default, because trusting X-Forwarded-For with no proxy in front lets every client choose its own limiter key. CORS_ORIGIN is a comma-separated list of scheme-plus-host origins; * and bare hostnames are refused, and reflecting any origin is a development convenience loadConfig refuses in production.

apps/api environment (routes)
ParameterRangeDefaultMeaning
PORTport8790
DATABASE_URLURL-unset = in-memory repositories, no persistence
INDEXER_URLURLhttp://127.0.0.1:42070the GraphQL endpoint and the /health probe
RPC_URLURLthe public Robinhood RPCthe chain reader and, for the bots, the wallet client
CHAIN_IDid4663the EIP-712 domain of theses and the perp envelope
MANIFEST_PATHpath-unset = /vaults and /launch answer empty, mirror of a v3 ref is 503 manifest_missing
VENUEsim or lightersimlighter forwards to the signer
SIGNER_URLURLhttp://127.0.0.1:8791
SIGNER_SECRETstring-sent as x-signer-secret; rotate both services together
LIGHTER_BASE_URLURLhttps://api.rh.lighter.xyz
LIGHTER_WS_URLURLwss://api.rh.lighter.xyz/stream
CACHE_MARKETS_MS> 0 ms15 000
CACHE_CANDLES_MS> 0 ms60 000
ADMIN_SECRETstring-POST /points/recompute; unset = 503 admin_not_configured
NODE_ENVdevelopment, test, productiondevelopmentproduction requires CORS_ORIGIN
RATE_LIMIT_WRITES_PER_MIN> 030per (route group, IP)
TRUST_PROXYbool or proxy-addr specoffonly behind a proxy that sets X-Forwarded-For
CORS_ORIGINorigins, comma-separated-unset = reflect any origin (development only)

Running without the API

The web runs without any of this. Each page hook in apps/web/lib/data.ts wraps its fetcher in React Query with no retry and a 15 s stale time and falls back to a fixture from lib/fixtures/ when the request fails, including the status-0 network error of an API that is not there. The returned isSampleData flag is what a page renders as the "Sample data" tag; it is true whenever what you are looking at came out of a fixture. The sample portfolio, referral and points belong to one SAMPLE_ADDRESS, and any other address gets an empty one so ?as=0x… never claims someone else's positions. On the API side the same idea is InMemoryIndexerSource, the in-memory repositories and SimVenue. What the web cannot fake is a contract: without a manifest every write button renders disabled.

The bots

The three bots live inside the API process and are opt-in by environment: startBots returns null, after logging which one is missing, unless BOTS, BOT_PRIVATE_KEY and MANIFEST_PATH are all set. A bot is one tick() away from doing nothing: it reads the world, decides, and returns a flat list of BotActions, each of which is a transaction hash, a venue order id or a skipped reason - a tick that decides to do nothing still says so, which is what makes the log worth reading. The runner's rules are that a bot must never be able to take the API down: every tick is wrapped, every rejection is logged and dropped, a tick still running when its interval comes round is skipped rather than stacked (three overlapping keeper ticks would be three transactions racing for one bounty), timers are unref'd so a SIGTERM never waits on them, and every bot runs once immediately at start.

One keeper tickThree sweeps in order, then the same attempt path for every candidate. A skip is a first-class outcome: the tick reports it, the guard records it, and the next tick retries with fresh data. Sweep (c) is the one deliberate blind send and declares itself with bounty {kind: none}, which bypasses the two bounty checks.
(a) enrolled positions(b) range orders(c) vaultsattempt(kind, target) → simulateAndSendyesthenfillabletheneach candidateyesnooknoyesnoyeskeeper tick · every BOT_INTERVAL_MSETH mark from MarketDatanull → every bounty is unpriceableactiveKeeperEnrollments()indexer · uncollectedFeesUsd · bountyBpsshouldHarvest · shouldRebalance × none Multicall3 batch · contract decidesbounty = fees × bountyBps → weirequired · null when unpricedopenRangeOrders() → fillable(id)no fill without a yesbounty = amountInUsd × 1 % → weiRANGE_ORDER_BOUNTY_BPS 100LPVault.harvest() per manifest vaultonce per VAULT_HARVEST_INTERVAL_MS 6 hbounty {kind: 'none'} · blind sendActionGuardblocked?cooldown 5 min · backoff 15 minsimulateContract · eth_calla revert here is the normal caserevert → skipped, failure notedbountypriceable?null → skip, never send blindestimateContractGas × getGasPricethe wei this send will costbounty ≥ gas × 3?GAS_BOUNTY_MULTIPLEbelow the floor → skipwriteContract → 1 confirmationa reverted receipt is a failuretxHash → guard.record → cooldownWhy the viewsThe bot never re-implementsthe policy: shouldHarvestand shouldRebalance are thecontract’s own answer.Fails closedOne market-data outagemust not switch the gasrule off for every actionin the tick (M11).

The keeper

One tick does three sweeps in order. (a) Enrolled positions: the indexer's active enrollments, then one shouldHarvest and one shouldRebalance per position batched through Multicall3, then harvest or rebalance for the ones that say yes. The views are the contract's own opinion, so the bot never re-implements the policy - @kerf/core's shouldHarvest exists for the UI to predict the same answer, not for the bot to second-guess it. The bounty is bountyBps of the indexer's uncollectedFeesUsd, the same figure for a rebalance because it collects the same pending fees. (b) Range orders: fillable(orderId) for every open order, then fill; no fill is attempted without a yes, since the contract would revert anyway. The bounty is RANGE_ORDER_BOUNTY_BPS (100) of amountInUsd. (c) Vaults: LPVault.harvest() has no cheap "is it worth it" view, so the bot blind-simulates it once per VAULT_HARVEST_INTERVAL_MS (6 h) per manifest vault; the simulation costs nothing and the vault's own minInterval rejects the rest.

Bounties fund the gas. Sweeps (a) and (b) price theirs in USD from the indexer and convert to wei at the ETH mark, because the rule is denominated in the gas token. When any input is missing the bounty is null and the action is skipped, not sent blind: a missed harvest costs the user nothing (the fees stay in the position and the next tick retries once the price is back), while "send if it simulates" once turned a market-data outage into a tick of unguarded gas for every enrolled position. Sweep (c) is the one deliberate blind send and declares itself with bounty: { kind: 'none' }, which is a different thing from a null bounty.

The one send path

Every bot write goes through ChainClient.simulateAndSend, in this order. First simulateContract, an eth_call against pending state; a revert here is the normal case, not an error - shouldHarvest was stale, the order is not fillable yet, graduate says TooYoung - and it is why bots are safe to run on a loop. Then, for a bounty-funded action, estimateContractGas × getGasPrice is the wei the transaction will cost, and the send is skipped when bounty < gasCost × GAS_BOUNTY_MULTIPLE (3): a keeper that clears $0.90 of bounty for $0.80 of gas is a slow way to lose money, and the margin has to absorb a base-fee spike between the estimate and the block. A null bounty skips before the estimate. Then writeContract, one confirmation, and a reverted receipt reported as a failure so the caller's backoff kicks in.

ActionGuard is the idempotency layer, keyed by (kind, target): after a success the pair is muted for COOLDOWN_MS (5 min), because the chain state that triggered it takes a moment to reach the indexer and re-sending would burn gas racing our own transaction; after a failure - a revert, or a bounty that never clears the floor - it is muted for FAIL_BACKOFF_MS (15 min), so a position that reverts every time costs one simulation per quarter hour instead of one per minute. The state is in-process on purpose: a restarted bot re-deriving everything from chain and indexer state is the correct behaviour, and the on-chain minInterval is the real guard. It also means two API replicas would double-send; bots belong on one replica.

The viem chain definition the client is built from must declare contracts.multicall3. Without it viem's multicall throws, readMany silently degrades to one HTTP call per read, and the Radar pool snapshot goes from one second to ninety-six.

Hedger and graduator ticksNeither bot earns a bounty, so both send whenever the simulation succeeds. The hedger reports every 15 minutes and re-hedges early on drift; the graduator tracks a token the first tick it sees it and graduates it the first tick after three days.
noyes0 or 1 orderyesnolater ticksnoyeshedger tick · 30 svaults(): "Hedged" names+ HEDGED_VAULTS overridepositionTokenId · positionspool().slot0() → sqrtPriceX96no LP → skiplpDelta() → target shorttoken0 exposure onlyvenue.positions(vault)current short → drift bpsreport due (15 min)or drift > 200 bps?in band → skiphedgeOrders()dead band · lot 0.001 · min $50venue.placeOrdersigner → Lighter, or simreportHedge(n, pnl, eq, now)USD × 1e6 · {kind: 'none'}unpaused while age ≤ 6 hgraduator tick · 60 sestablishedPools()− graduations() · launch sidefirstSeen(token) × none Multicall3 batchread failed → skipfirstSeen == 0?track(token)starts the 3-day clockage ≥ 3 days?GRADUATION_AGEnot yet → quietgraduate(token)createV3Vault via factoryThinPool, TooYoungActionGuard → simulate{kind: 'none'} · no bountygraduation row filledCadence vs stalenessThe seed vaults pause at maxHedgeAge = 6 h;a 15 min report cadence leaves 24 misses of slack.Quiet revertsThinPool, TooYoung, NotTracked andAlreadyGraduated are the steady state,logged as skips; the backoff stops there-simulation every minute.

The hedger

A HedgedLPVault is an LP position plus a short that cancels its price exposure, and the LP leg's delta is not a constant: as the price walks through the range the position converts between token0 and token1 continuously, so the short has to be re-sized. Once per vault per tick the hedger reads the vault's positionTokenId(), the NPM's positions(tokenId) for liquidity and ticks and the pool's slot0() for the price; lpDelta from @kerf/core gives the token0 balance, which is the risk asset (NVDA in an NVDA/WETH vault) - token1 is the numeraire and carries no delta worth hedging. It compares that target with the short already held on the venue, and only acts when a report is due (HEDGE_REPORT_INTERVAL_MS, 15 min) or the drift exceeds HEDGE_DRIFT_BPS (200) of the target - a fast move must not wait for the cadence. hedgeOrders applies the dead band, the venue lot size (HEDGE_LOT_SIZE 0.001, rounding down so rounding never over-hedges) and the minimum notional (HEDGE_MIN_NOTIONAL_USD 50), and returns zero or one order, which goes to the venue - Lighter through the signer, or the sim. Finally reportHedge(notionalUsd, unrealizedPnlUsd, equityUsd, asOf) puts the venue state on chain in USD × 1e6 with asOf as the bot's clock, which is what the vault's totalAssets() and its staleness pause are built on. Reporting pays no bounty; it is the price of running the vault, so it sends whenever it simulates.

The vaults it works are the manifest entries whose name contains "Hedged" plus the HEDGED_VAULTS override, and the market is the Lighter symbol for the pool's token0 unless VAULT_MARKETS names another. The seed vaults are deployed with maxHedgeAge of 6 hours, so a 15-minute cadence leaves room for many missed reports before deposits pause; the comment in hedger.ts still says 1 h, which was the earlier spec value - the deploy constant in KerfWiring is the one in force. What the hedger key can and cannot do is the subject of Hedged vaults & trust model.

The graduator

LaunchPipeline is deliberately half off-chain: the contract checks age and pool liquidity, while the rest of Established (market cap, trade count) is a flag the indexer computes, and this bot is the join. For every pool the indexer flags Established whose launch token - the non-numeraire side - has not graduated, it reads firstSeen(token) in one batch. Zero means track(token), which starts the on-chain three-day clock; GRADUATION_AGE_SECONDS (3 days) elapsed means graduate(token), which deploys the token's LPVault; anything in between is nothing, quietly, because there is nothing to say every minute. Neither call pays a bounty, so both go out with { kind: 'none' }. graduate reverting with ThinPool, TooYoung, NotTracked or AlreadyGraduated is the expected outcome for most candidates on most ticks, so those are logged as skips rather than warnings, and the guard's failure backoff keeps them from being re-simulated every minute. The contract half is in Launch & pre-market perp.

apps/api environment (bots)
ParameterRangeDefaultMeaning
BOTSkeeper, hedger, graduatorempty = disabledcomma-separated; an unknown name is a ConfigError
BOT_PRIVATE_KEYhex key-a separate hot key; also the hedger on the hedged vaults, so set HEDGER to its address at deploy
BOT_INTERVAL_MS> 0 ms60 000keeper and graduator
HEDGER_INTERVAL_MS> 0 ms30 000the hedger runs twice as often
COOLDOWN_MS> 0 ms300 000ActionGuard mute after a success
FAIL_BACKOFF_MS> 0 ms900 000ActionGuard mute after a failure
VAULT_HARVEST_INTERVAL_MS> 0 ms21 600 000the blind LPVault.harvest() cadence, 6 h
HEDGED_VAULTSaddressesemptyextra hedged vaults beyond the manifest names containing Hedged
VAULT_MARKETS0xvault:MARKET[:BASE], …emptyoverrides the token0 → Lighter symbol lookup
HEDGE_REPORT_INTERVAL_MS> 0 ms900 000must stay well under the vault's maxHedgeAge (6 h on the seed vaults)
HEDGE_DRIFT_BPS≥ 0 bps200delta drift of the target short that re-hedges out of band
HEDGE_MIN_NOTIONAL_USD> 0 USD50smaller adjustments are not sent
HEDGE_LOT_SIZE> 0 base units0.001hedge sizes round down to it
Read the code
  • apps/indexer/ponder.config.ts sources, start blocks, the no-manifest fallback
  • apps/indexer/ponder.schema.ts every table and column, with its comment
  • apps/indexer/src/logic/ pricing, tvl, stats, uptime and the Kerf state machines
  • apps/indexer/scripts/smoke.sh anvil fork → deploy → canary → ponder → GraphQL asserts
  • apps/api/src/app.ts buildApp, the write budget hook, the error shape
  • apps/api/src/radar.ts buildRadar and toRadarWireRow
  • apps/api/src/chain/poolSnapshot.ts Radar without the indexer
  • apps/api/src/perp/auth.ts requirePerpAuth, the order of checks
  • apps/api/src/theses/service.ts create, leaderboard, mirror
  • apps/api/src/routes/wire.ts domain model → wire shape
  • apps/api/src/routes/types/web.types.test.ts the drift guard
  • apps/api/src/indexer/queries.schema.test.ts every GraphQL field against the schema
  • apps/api/src/bots/chain.ts simulateAndSend and the gas rule
  • apps/api/src/bots/keeper.ts the three sweeps
  • apps/api/src/bots/hedger.ts lpDelta → hedgeOrders → reportHedge
  • apps/api/src/bots/graduator.ts track and graduate
  • apps/web/lib/data.ts the fixture fallback and isSampleData