import { Tidx } from 'tidx.ts' import type { Chain } from 'viem' import type * as Auth from './Auth.js' import * as Credential from './Credential.js' import * as Errors from './Errors.js' import type * as Log from './Log.js' import * as Timeout from './Timeout.js' import * as Viem from './Viem.js' /** Default Tempo indexer (TIDX) base URLs. */ export const url: Record = { 4217: 'https://indexer.tempo.xyz', 42431: 'https://indexer.testnet.tempo.xyz', } /** TIDX query client. */ export type Client = Tidx.Tidx /** Gets a TIDX query client for a Tempo chain id and request context. */ export type GetClient = ( chainId?: Viem.ChainId, options?: Pick, ) => Client /** Shared, per-chain, or dynamically resolved TIDX URLs. */ export type UrlResolver = Viem.UrlResolver /** * Resolves the effective TIDX config for a single request. * * Accepts either a static config object or a per-request resolver function that * receives a {@link Viem.resolveRpc.Context} (`chainId` plus the request * `principal`), so callers can route traffic per caller — e.g. send anonymous * (`principal.type === 'public'`) traffic to a separate, no-auth indexer. A * missing `baseUrl` falls back to the built-in public host map ({@link url}); * missing `auth` sends no Authorization header. */ export function resolve( tidx: getClient.Tidx | undefined, context: Viem.resolveRpc.Context, ): resolve.Resolved { const config = typeof tidx === 'function' ? tidx(context) : tidx return { ...Credential.resolveCredential({ chainId: context.chainId, value: config?.auth }), ...(context.zone && config?.zoneHeaders ? { zoneHeaders: config.zoneHeaders } : {}), baseUrl: Viem.resolveUrl(config?.baseUrl, context.chainId) ?? context.zone?.rpcUrls['tidx']?.http[0] ?? url[context.chainId], } } export declare namespace resolve { /** Resolved TIDX config for a single request. */ type Resolved = { /** * Resolved TIDX base URL, or `undefined` when neither a configured `tidx` * nor the built-in {@link url} map covers the chain (e.g. an unconfigured * localnet). A `undefined` base URL falls through to the TIDX client default. */ baseUrl: string | undefined /** Basic auth credentials, or undefined to send no Basic auth. */ basicAuth?: string | undefined /** Bearer auth credential, or undefined to send no Bearer auth. */ bearerAuth?: string | undefined /** Headers sent to Zone indexer upstreams. */ zoneHeaders?: Viem.ZoneHeaders | undefined } } export function getClient(options: getClient.Options): Client { const { chainId = Viem.defaultChainId, principal = null, tidx, zone } = options const { baseUrl, basicAuth, bearerAuth, zoneHeaders } = resolve(tidx, { chainId, principal, zone, }) return withTransientRetry( Tidx.create({ baseUrl, basicAuth, bearerAuth, chainId, headers: zoneHeaders, }), ) } /** * Builds a memoized {@link GetClient} that constructs one TIDX client per * resolved upstream and reuses it. Clients are stateless HTTP transports, so * reusing them avoids per-call construction cost. The cache key is the resolved * output (`baseUrl` + auth + `chainId`), not the principal, so a resolver * `tidx` may branch on any principal field while client cardinality stays * bounded by the number of distinct upstreams — callers that resolve to the same * upstream share a client. */ export function createGetClient(options: createGetClient.Options = {}): GetClient { const { defaultChainId = Viem.defaultChainId, tidx } = options const clients = new Map() return (chainId = defaultChainId, options = {}) => { const { principal = null, zone } = options // Resolve to derive the cache key; `getClient` re-resolves to build the // client on a miss. `resolve` is pure and cheap, so the extra call is free. const { baseUrl, basicAuth, bearerAuth, zoneHeaders } = resolve(tidx, { chainId, principal, zone, }) const cacheKey = `${baseUrl}|${basicAuth ?? ''}|${bearerAuth ?? ''}|${JSON.stringify(zoneHeaders)}|${chainId}` const existing = clients.get(cacheKey) if (existing) return existing const client = getClient({ chainId, principal, tidx, zone }) clients.set(cacheKey, client) return client } } /** * Number of additional `fetch` attempts for transient application-level * indexer errors. The first attempt plus this many retries. * * Combined with {@link transientRetryDelay} this gives a ~3.75s backoff window * (250 + 500 + 1000 + 2000ms over five attempts). The heaviest reads — e.g. * the unfiltered global swap scan — can momentarily trip the indexer's `db * error` under concurrent load, and a ~1s window was too short to ride those * bursts out, surfacing them to callers as a 502. */ const transientRetryCount = 4 /** Base backoff (ms) between transient-error retries; doubles each attempt. */ const transientRetryDelay = 250 /** * Application-level indexer failures that are transient and safe to retry on * an idempotent read. The indexer surfaces transient ClickHouse failures (a * busy/contended planner, a query-execution hiccup) as the literal message * `db error`; timeouts and connection resets are likewise non-deterministic. * `error sending request` is how TIDX reports a ClickHouse Cloud connection * dropped at the server's execution cap — typically a cold object-storage * cache read that warms across attempts, so retrying genuinely helps. */ const transientErrorPattern = /db error|connection reset|connection refused/i /** Indexer failures caused by the query execution deadline. */ const queryTimeoutPattern = /timeout|timed out|error sending request/i /** * Escapes a free-form string for safe embedding inside a single-quoted SQL * string literal sent to the indexer. TIDX parses every query with an ANSI * SQL parser before re-rendering it for the engine, and ANSI strings have * exactly one special character inside `'…'`: the quote itself, escaped by * doubling (`''`). Backslash escaping (`\'`) is **rejected** with a parse * error, and a backslash is an ordinary character on both engines (verified * against the deployed indexer on `postgres` and `clickhouse`). * * Most interpolated values (addresses, hashes, ISO timestamps) are already * schema-constrained to charsets that cannot contain a quote; reach for this * whenever a query must interpolate genuinely free-form user input (e.g. the * token `currency` filter). */ export function escape(value: string) { return value.replaceAll("'", "''") } /** * Wraps a TIDX client so `fetch` retries transient errors under **one** * shared budget ({@link transientRetryCount} retries with backoff). * * Two transient failure modes are covered: HTTP-level failures (408/429/5xx) * and the indexer's transient ClickHouse errors, which arrive as **HTTP 200** * with an `{ ok: false, error: 'db error' }` body (a `FetchRequestError` * whose `status` is 200). The raw `tidx.ts` client would retry the HTTP-level * mode itself (5 attempts), which stacked multiplicatively under this wrapper * — one flapping query shape could cost ~25 upstream executions — so the * wrapper disables the client-level retries (`retryCount: 1`) and owns the * whole budget: at most {@link transientRetryCount} + 1 upstream executions. * Deterministic errors (e.g. a malformed query) fall through immediately. */ export function withTransientRetry(client: Client): Client { const fetch = client.fetch.bind(client) return { ...client, fetch: async (options) => { const signal = Timeout.signal({ signal: options.signal }) let lastError: unknown for (let attempt = 0; attempt <= transientRetryCount; attempt++) { try { // `retryCount: 1` caps the inner client at a single HTTP attempt so // the combined budget stays this wrapper's alone. return await fetch({ ...options, retryCount: 1, signal }) } catch (error) { if (!isTransientError(error)) throw error lastError = error if (attempt < transientRetryCount) await Timeout.wait({ milliseconds: transientRetryDelay * 2 ** attempt, signal, }) } } throw lastError }, } } /** Adds request-scoped timing and redacted failure diagnostics to a TIDX client. */ export function observe(client: Client, options: observe.Options): Client { const fetch = client.fetch.bind(client) return { ...client, fetch: (parameters) => options.time(async () => { try { const result = await fetch(parameters) options.record(undefined) return result } catch (cause) { options.record(providerFailure(cause)) throw cause } }), } } export declare namespace observe { /** Request-scoped observability hooks for TIDX calls. */ type Options = { /** Records a failure, or clears one after a later successful query. */ record: (failure: Log.ProviderFailure | undefined) => void /** Measures the complete TIDX call, including retries. */ time: (fn: () => Promise) => Promise } } /** Returns bounded failure metadata for a TIDX request. */ export function providerFailure(cause: unknown): Log.ProviderFailure { const base = { id: 'tidx', operation: 'query' } as const if ( cause instanceof DOMException && (cause.name === 'AbortError' || cause.name === 'TimeoutError') ) return { ...base, failure: 'timeout' } const status = Errors.getStatus(cause) if (isQueryTimeout(cause)) return status === 408 ? { ...base, failure: 'timeout', status } : { ...base, failure: 'timeout' } if (status !== undefined && status >= 200 && status < 300) return { ...base, failure: 'payload' } if (status === 422) return { ...base, failure: 'query', status } if (status === 429) return { ...base, failure: 'rate_limit', status } if (status !== undefined) return { ...base, failure: 'http', status } if (cause instanceof TypeError) return { ...base, failure: 'network' } return { ...base, failure: 'unknown' } } /** Returns bounded failure metadata when a TIDX response represents a failed query. */ export async function responseFailure( response: Response, ): Promise { if (!response.ok) return providerFailure(response) // Parsing is observational; clone the response so the raw route can return its body unchanged. const body: unknown = await response .clone() .json() .catch(() => undefined) if (typeof body === 'object' && body !== null && 'ok' in body && body.ok === true) return undefined return { failure: 'payload', id: 'tidx', operation: 'query' } } /** Whether a TIDX response is a deterministic rejection of the caller's query. */ export function isQueryRejection(cause: unknown): boolean { const status = Errors.getStatus(cause) return status === 400 || status === 422 } /** Whether a failed TIDX request exhausted its query execution deadline. */ export function isQueryTimeout(cause: unknown): boolean { if (!(cause instanceof Tidx.FetchRequestError)) return false if (cause.status === 408) return true return cause.status >= 200 && cause.status < 300 && queryTimeoutPattern.test(cause.message) } /** * True for deterministic indexer failures a retry cannot fix: a * `FetchRequestError` outside {@link isTransientError}'s transient classes, * e.g. an HTTP 422 `db error` for a query shape the planner cannot execute. */ export function isDeterministicError(error: unknown): boolean { return error instanceof Tidx.FetchRequestError && !isTransientError(error) } /** * True for transient indexer errors: an HTTP-level transient failure * (408/429/5xx), or a `FetchRequestError` that came back on a **2xx** HTTP * response whose message matches a known transient failure. * * The 2xx gate on the message pattern matters. The indexer reports two * different `db error`s: a transient ClickHouse hiccup arrives on an HTTP 200 * with `{ ok: false, error: 'db error' }` (worth retrying), whereas an HTTP * 422 `db error` means the planner cannot execute that query shape at all — a * deterministic failure that will never succeed on retry. Retrying the latter * would just burn the backoff budget before failing anyway, so 4xx statuses * other than 408/429 are excluded. */ function isTransientError(error: unknown): boolean { if (!(error instanceof Tidx.FetchRequestError)) return false if (isQueryTimeout(error) || error.status === 429 || error.status >= 500) return true return error.status >= 200 && error.status < 300 && transientErrorPattern.test(error.message) } export declare namespace getClient { /** Options for getting a TIDX query client. */ type Options = { /** Tempo chain id. */ chainId?: Viem.ChainId | undefined /** Request principal, or `null`/omitted for a trusted non-request caller. */ principal?: Auth.Principal | null | undefined /** TIDX query client options. */ tidx?: Tidx | undefined /** Zone chain metadata used for TIDX URL resolution. */ zone?: Chain | undefined } /** * TIDX query client options: either a static config, or a per-request * resolver function that receives a {@link Viem.resolveRpc.Context} and returns * the config to use (e.g. routing anonymous callers to a public, no-auth * indexer). */ type Tidx = | { /** Shared or per-chain TIDX credentials. Values containing `:` use Basic auth; others use Bearer auth. */ auth?: Credential.ChainCredential | undefined /** Resolves the TIDX base URL for a Tempo chain id, or a static base URL. */ baseUrl?: UrlResolver | undefined /** Headers sent to Zone indexer upstreams. */ zoneHeaders?: Viem.ZoneHeaders | undefined } | ((context: Viem.resolveRpc.Context) => { /** Shared or per-chain TIDX credentials, or undefined for none. */ auth?: Credential.ChainCredential | undefined /** TIDX base URL to use; falls back to the built-in public host when omitted. */ baseUrl?: string | undefined /** Headers sent to Zone indexer upstreams. */ zoneHeaders?: Viem.ZoneHeaders | undefined }) } export declare namespace createGetClient { /** Options for building a memoized TIDX client resolver. */ type Options = { /** Tempo chain id used when a caller omits one. */ defaultChainId?: Viem.ChainId | undefined /** TIDX query client options applied to every constructed client. */ tidx?: getClient.Tidx | undefined } }