// Pools — the CLOB pool records and their market bindings. // // A pool address is a TIME-VARYING binding: settlement-extraction v2 recycles one // pool across successive markets, so `(pool, nonce)` identifies a market's slice // of a pool's history and a pool alone never identifies a market. These reads are // how a caller resolves that binding — which market a pool is hosting now, which // it hosted, and which pools are free for the next one. // // Shared across kinds: every kind's CLOB is a pool from the same factory. import * as IndexerRead from "./indexerRead.js"; import * as Markets from "./markets.js"; import { graphql } from "./gql/gql.js"; import { IndexerError } from "./errors.js"; import type { PublicClient } from "viem"; import * as ModuleAbi from "./moduleAbi.js"; import type { Address } from "viem"; import type { Market } from "./markets.js"; /** * Resolve a market by its pool address (one query; no live watch), as the * discriminated {@link Market} union — null if no market rests on that pool. * For spot/perp the pool address IS the market id, but binary markets are keyed * by bytes32 marketId, so this looks them up by the `poolAddress` column. * * RECYCLE CAVEAT (settlement-extraction v2): a binary pool serves SUCCESSIVE * markets, so several binary rows can share one `poolAddress`. This returns the * NEWEST (the pool's current/latest binding). To address a specific past market * of a recycled pool, key by `marketId` (or match `nonce`) instead. */ export async function getMarketByPool(pool: string, indexerUrl: string): Promise { return (await listMarketsByPool(pool, { limit: 1 }, indexerUrl))[0] ?? null; } /** * Every market a pool has hosted, newest first — one row for a SPOT/PERP pool, * the full recycle history for a BINARY pool (which is REUSED across successive * markets, nonce++ each time). The first row is the pool's current market. */ export async function listMarketsByPool( pool: string, opts: { limit?: number } = {}, indexerUrl: string, ): Promise { const data = await IndexerRead.gqlRequest( MarketsByPoolQuery, { pool: pool.toLowerCase(), limit: opts.limit ?? 50 }, indexerUrl, ); return data.Market.map(Markets.toMarket); } // prettier-ignore const MarketsByPoolQuery = graphql(` query MarketsByPool($pool: String!, $limit: Int) { Market( where: { poolAddress: { _eq: $pool } } order_by: { createdAtTimestamp: desc } limit: $limit ) { ...MarketFields } } `); // ---------------------------------------------------------------- pool reuse // Settlement-extraction v2: a BinaryPool is a long-lived contract REUSED across // successive markets. The indexer keeps a per-pool aggregate (`Pool`) and the // full audit trail of pool→market bindings (`PoolBinding`) — these reads back // the explorer's "every market this pool served" view. /** * One interval in a pool's life during which it was bound 1:1 to a single * market (indexer `PoolBinding`; id = `${pool}_${nonce}`). `MarketCreated` * OPENS a binding; `PoolReleased` or the next `MarketCreated` on the same pool * CLOSES it. An open binding (`toBlock` null) is the pool's current market. * * @category pools */ export interface PoolBindingRecord { /** `${poolAddress}_${nonce}` */ id: string; /** Lowercased pool address. */ poolAddress: string; /** Lowercased bytes32 marketId this binding served. */ marketId: string; /** Pool market nonce for this binding (decimal string). */ nonce: string; /** Block the binding opened in (the MarketCreated; decimal string). */ fromBlock: string; /** Log index of the opening event within its block. */ fromLogIndex: number; /** Timestamp (unix seconds) the binding opened. */ fromTimestamp: string; /** Null while the binding is open (the pool's current market). */ toBlock: string | null; /** Log index of the closing event; null while the binding is open. */ toLogIndex: number | null; /** Timestamp (unix seconds) the binding closed; null while open. */ toTimestamp: string | null; /** * How the binding closed: `"Released"` (PoolReleased) | `"Rotated"` (the next * MarketCreated recycled the pool onward); null while open. */ closedBy: "Released" | "Rotated" | null; } /** * A pool's full binding history, newest (highest nonce) first — every market * the pool has served. The first row with `toBlock === null` is the current * binding; a fully-released pool has no open row. */ export async function getPoolBindings(pool: string, indexerUrl: string): Promise { const data = await IndexerRead.gqlRequest(PoolBindingsQuery, { pool: pool.toLowerCase() }, indexerUrl); // `closedBy` is a plain String column, narrowed here to the only values the // indexer writes ("Rotated" | "Released" | null — see indexer/src/handlers/ // binary.ts; confirmed against the live indexer). Same asserted-not-proven class // as the enum-scalars in codegen.ts. return IndexerRead.narrowIndexerInvariant(data.PoolBinding); } /** * The indexer's per-pool aggregate (`Pool`; id = lowercased pool address) — the * long-lived BinaryPool contract that outlives any single market. * * @category pools */ export interface IndexedPool { /** Lowercased pool address (== address). */ id: string; /** Lowercased pool address (== id). */ address: string; /** Collateral token the pool is bound to for its whole life (lowercased). */ collateral: string | null; /** * The pool's creator — its first-deploy market creator, the only party that * can reuse it (lowercased). */ creator: string | null; /** * marketId of the pool's CURRENT binding; null when finalized + released and * awaiting reuse. */ currentMarketId: string | null; /** Pool market nonce of the current binding (decimal string). */ currentNonce: string | null; /** Number of markets this pool has served (== the latest nonce). */ generationCount: number; /** Timestamp (unix seconds) of the pool's first MarketCreated. */ createdAtTimestamp: string; /** Timestamp (unix seconds) of the last binding change. */ updatedAtTimestamp: string; } /** * One pool's aggregate row by address — null if the indexer has never seen a * MarketCreated on it. */ export async function getPool(address: string, indexerUrl: string): Promise { const data = await IndexerRead.gqlRequest(PoolByPkQuery, { id: address.toLowerCase() }, indexerUrl); return data.Pool_by_pk ?? null; } // prettier-ignore const PoolBindingsQuery = graphql(` query PoolBindings($pool: String!) { PoolBinding(where: {poolAddress: {_eq: $pool}}, order_by: {nonce: desc}) { id poolAddress marketId nonce fromBlock fromLogIndex fromTimestamp toBlock toLogIndex toTimestamp closedBy } } `); // prettier-ignore const PoolByPkQuery = graphql(` query PoolByPk($id: String!) { Pool_by_pk(id: $id) { id address collateral creator currentMarketId currentNonce generationCount createdAtTimestamp updatedAtTimestamp } } `); /** * A creator's free (finalized + released, reusable) pools for `collateral`, * LIFO order (the LAST entry is popped first on the creator's next * createMarket). Pure chain read (no signer). */ export async function getFreePools( creator: Address, collateral: Address, module: Address, client: PublicClient, ): Promise { const pools = await client.readContract({ address: module, abi: ModuleAbi.binaryModuleReadAbi, functionName: "getFreePools", args: [creator, collateral], }); return [...pools]; } // ---------------------------------------------------------------- type scope, as pools // // A market type is not a column on Fill, Order or StopOrder. The obvious filter — // `market: { marketType: { _eq } }` — is a RELATIONSHIP predicate, and Hasura compiles // a relationship predicate to a correlated EXISTS on Market per candidate row. Postgres // cannot fold that into a bitmap over the `maker` / `taker` / `owner` indexes, so it // walks the `timestamp` index testing EXISTS row by row. Measured on the development // indexer, one wallet's fills, `limit: 50`: `stream timeout` (>60s) with the predicate, // 102ms without it. Three such reads every 5s per open wallet page saturated the shared // one-vCPU Cloud SQL instance and stalled ingestion on both slots (2026-09-02). // // So a scoped read resolves its scope HERE, to values for the direct, indexed column — // `pool` on Fill, `market_id` on Order and StopOrder. Two facts shape the answers. // // `Market.id` IS the pool address for SPOT and PERP, so those type sets are small (3 // and ~20 pools) and one such pool's market id is the pool itself. Binary pools are // recycled across thousands of markets, so the BINARY TYPE is "not a spot or perp pool" // (an `_nin` over ~23 addresses, never an `_in` over thousands) while a single BINARY // POOL keeps the `market: { poolAddress }` relationship form — a recycled pool has // hosted ~2,000 markets on the development indexer, so an `_in` of its ids is the worse // shape, and every Order read that takes a pool also carries `owner`, which bounds that // EXISTS to the wallet's own rows. The read that removes it for good is a denormalized // `pool` column on Order and StopOrder, as Fill already has. // // A pool's type comes from the pool's OWN row, never from membership of a type set. // Set membership is an inference that goes stale: a spot pool created after the SPOT set // was memoized is not in it, and "not spot, not perp, therefore binary" would then scope // a spot read to nothing. The per-pool read is authoritative and equally cheap. // // Everything here is memoized per indexer URL with a short TTL, under a hard entry cap // with eviction — bounded pure memoization whose key carries every environmental input, // the shape SDK-ARCH-002 permits at module scope. Expiry is decided BEFORE anyone // awaits, so callers landing in the same tick share one refresh. The set cap is a // separate guard: a set silently cut at a row limit would silently drop rows from every // read built on it, so a set past its cap throws instead. const POOL_SET_TTL_MS = 60_000; const POOL_SET_CAP = 1_000; // Entries are (url, type) — two per indexer — plus (url, pool) for every pool a process // scopes a read to. A long-lived keeper walking many pools must not grow the map without // end, so it is capped and evicts rather than relying on a re-read to expire a key. const MEMO_MAX_ENTRIES = 256; interface MemoEntry { promise: Promise; expires: number; } const memoized = new Map(); /** * One in-flight promise per key until it expires. The stored value is `unknown` * because the map is heterogeneous — pool SETS and single pool TYPES share it — and * the key namespace, not the type system, is what pairs a key with its value type. * Every caller is a private function below that owns both halves of that pairing. */ function memo(key: string, create: () => Promise): Promise { const now = Date.now(); const hit = memoized.get(key); if (hit && hit.expires > now) return hit.promise as Promise; if (memoized.size >= MEMO_MAX_ENTRIES) evict(now); const promise = create(); memoized.set(key, { promise, expires: now + POOL_SET_TTL_MS }); // A failure is not an answer: evict so the next caller asks again (the AsyncCache rule). promise.catch(() => { if (memoized.get(key)?.promise === promise) memoized.delete(key); }); return promise; } /** Expired entries first; then oldest-inserted, since Map preserves insertion order. */ function evict(now: number): void { for (const [k, v] of memoized) if (v.expires <= now) memoized.delete(k); for (const k of memoized.keys()) { if (memoized.size < MEMO_MAX_ENTRIES) break; memoized.delete(k); } } /** Rows past the cap are the truncation signal (the aggregateCountBounded pattern). */ function capped(values: readonly string[], what: string): string[] { if (values.length > POOL_SET_CAP) { throw new IndexerError( "poolScope", `${what} has ${POOL_SET_CAP}+ entries; a set cut at the cap would silently drop rows`, ); } return values.map((v) => v.toLowerCase()); } /** * Drop every memoized pool set and pool type. Tests use it so one file's stubbed * registry cannot leak into the next test through the TTL. */ export function clearPoolSets(): void { memoized.clear(); } /** * Every pool currently hosting a market of `type`, lowercased. SPOT and PERP are a * handful of addresses; BINARY is thousands and is not what you want — use * {@link poolScope}, which expresses binary as the complement. */ export function poolsOfType(type: Markets.MarketType, indexerUrl: string): Promise { return memo(`${indexerUrl}\u0000type:${type}`, async () => { const data = await IndexerRead.gqlRequest( PoolsByTypeQuery, { where: { marketType: { _eq: type } }, limit: POOL_SET_CAP + 1 }, indexerUrl, ); return capped( data.Market.map((m) => m.poolAddress), `${type} pools`, ); }); } /** * The Hasura comparison that scopes a `pool` / `market_id` column to one market type: * `{ _in }` over the type's pools for SPOT and PERP, `{ _nin }` over spot ∪ perp for * BINARY. Drop-in for the `market: { marketType }` relationship predicate, minus the * correlated EXISTS — see the section header. */ export async function poolScope( type: Markets.MarketType, indexerUrl: string, ): Promise<{ _in: string[] } | { _nin: string[] }> { if (type !== "BINARY") return { _in: await poolsOfType(type, indexerUrl) }; const [spot, perp] = await Promise.all([poolsOfType("SPOT", indexerUrl), poolsOfType("PERP", indexerUrl)]); return { _nin: [...spot, ...perp] }; } /** * A pool's market type, read from the pool's own newest market row — `null` when the * indexer has no market for it yet. A pool's type is fixed for its life (a recycle * keeps the kind), so this is cacheable; it is read per pool rather than inferred from * the type sets because set membership goes stale for a newly created pool. */ function poolMarketType(pool: string, indexerUrl: string): Promise { return memo(`${indexerUrl}\u0000pooltype:${pool}`, async () => { const data = await IndexerRead.gqlRequest(PoolTypeQuery, { pool }, indexerUrl); return data.Market[0]?.marketType ?? null; }); } /** * The `where` fragment that scopes an Order / StopOrder read by pool and/or type — the * one place the two compose, replacing `market: { marketType, poolAddress }`. * * Both constraints always narrow. A pool that is not of the requested type matches * NOTHING, exactly as the relationship form it replaces did: passing a spot pool to a * perp read must not return spot rows, whose `orderIdRaw` addresses a different * registry. A pool of a known SPOT / PERP type is its own market id, a direct `_eq` on * the indexed column; a known BINARY pool keeps the relationship form (see the section * header). A pool the indexer has no market row for yet falls back to that same * relationship form, carrying whichever constraints were asked for — correct for any * type, and merely costlier on a path that only exists until the pool is indexed. * Neither scope returns `undefined`, so the caller adds no predicate. */ export async function marketScope( opts: { pool?: string; marketType?: Markets.MarketType }, indexerUrl: string, ): Promise | undefined> { if (opts.pool == null) { if (opts.marketType == null) return undefined; return { market_id: await poolScope(opts.marketType, indexerUrl) }; } const pool = opts.pool.toLowerCase(); const type = await poolMarketType(pool, indexerUrl); if (type == null) { // Unknown pool: express both constraints the only way that cannot be wrong. const market: Record = { poolAddress: { _eq: pool } }; if (opts.marketType != null) market.marketType = { _eq: opts.marketType }; return { market }; } if (opts.marketType != null && opts.marketType !== type) return { market_id: { _in: [] } }; if (type === "BINARY") return { market: { poolAddress: { _eq: pool } } }; return { market_id: { _eq: pool } }; } // prettier-ignore const PoolTypeQuery = graphql(` query PoolType($pool: String!) { Market(where: { poolAddress: { _eq: $pool } }, order_by: { createdAtTimestamp: desc }, limit: 1) { marketType } } `); // prettier-ignore const PoolsByTypeQuery = graphql(` query PoolsByType($where: Market_bool_exp!, $limit: Int) { Market(where: $where, distinct_on: poolAddress, order_by: { poolAddress: asc }, limit: $limit) { poolAddress } } `);