import { Hex as core_Hex } from 'ox' import { Addresses } from 'viem/tempo' import * as z from 'zod/mini' /** * Attaches an OpenAPI description to an object/array schema that is later * derived from via `z.partial`/`z.extend`. `zod/mini` exposes descriptions only * through `.check(z.describe(...))`, but that adds a refinement which those * derivations reject. Cloning and registering the description directly (as * classic zod's `.describe()` does) keeps the schema refinement-free. Plain * fields use `.check(z.describe(...))` directly. */ export function describe( schema: schema, description: string, ): schema { const cloned = schema.clone() z.globalRegistry.add(cloned, { ...z.globalRegistry.get(schema), description }) return cloned as schema } /** Representative account (EOA) address used as the default OpenAPI example. */ const accountExample = '0xbe058e1c4df8a4366a387bf595b284246a93039e' // Pre-transform input for a 0x-prefixed 20-byte address. The OpenAPI example is // attached here (pre-transform) via `z.meta` so it surfaces on path/query // params; the lowercasing transform is applied after. const addressInput = z.templateLiteral(['0x', z.string().check(z.regex(/^[0-9a-fA-F]{40}$/))]) // TODO(ox/zod): replace with ox's `Address` zod schema once ox ships a `zod` // entrypoint, so the wire format stays in lockstep with ox/viem. /** Lowercase 0x-prefixed 20-byte account address. */ export const Address = address(accountExample) /** * Builds an `Address`-shaped schema with the given OpenAPI example. Use when a * path or query parameter needs a more specific address example. */ export function address(example: core_Hex.Hex) { if (!/^0x[0-9a-fA-F]{40}$/.test(example)) throw new Error(`address example ${example} is not a 0x-prefixed 20-byte address`) return z .pipe( addressInput.check(z.meta({ examples: [example] })), // `String.prototype.toLowerCase` widens its receiver from `Hex.Hex` to // `string`. Cast the result back so the schema's inferred output keeps the // hex-literal type — this is sound because lowercasing preserves both the // `0x` prefix and the upstream regex constraint. z.transform((value) => value.toLowerCase() as core_Hex.Hex), ) .check( z.describe( 'An account address — the `0x`-prefixed 20-byte identifier for a wallet or contract, returned in lowercase.', ), ) } /** * Lowercase 0x-prefixed 20-byte TIP-20 token contract address. The OpenAPI * example must be attached to the inner pre-transform schema (path/query * params read examples from the pipe input), so callers that need a different * example per field build a fresh `TokenAddress` via `tokenAddress(example)` * instead of overriding via an outer `.check(z.meta(...))`. */ export const TokenAddress = tokenAddress(Addresses.pathUsd) /** * Builds a `TokenAddress`-shaped schema with the given OpenAPI example. Use * when a path/query param wants a different example than the shared * `TokenAddress` default (e.g. the two sides of a trading pair). * * The example must be a TIP-20 token address (`0x20c` prefix); a non-TIP-20 * example in the docs would be misleading because the route validates the * value as a TIP-20 token contract at runtime. */ export function tokenAddress(example: core_Hex.Hex) { if (!example.toLowerCase().startsWith('0x20c')) throw new Error( `tokenAddress example ${example} is not a TIP-20 token (must start with 0x20c).`, ) return z .pipe( addressInput.check(z.meta({ examples: [example] })), z.transform((value) => value.toLowerCase() as core_Hex.Hex), ) .check( z.describe( 'A TIP-20 token contract address — the `0x`-prefixed 20-byte identifier for the token, returned in lowercase.', ), ) } /** Maps caller-facing chain aliases to their current Tempo chain id. */ export const chainIdAlias = { // TODO: Remove this alias after clients migrate to chain 1424310003. 1_424_310_001: 1_424_310_003, mainnet: 4217, testnet: 42431, } as const /** Resolves a numeric chain id through the caller-facing alias map. */ export function resolveChainId(chainId: number): number { return chainIdAlias[chainId as keyof typeof chainIdAlias] ?? chainId } /** * Tempo chain id. Accepts a chain alias (`mainnet`/`testnet`) or any positive * integer chain id (a number or numeric string), normalizing all forms to a * numeric chain id. Arbitrary ids are allowed so a self-hosted deployment can * serve a localnet (e.g. `31337`); the deployment must configure an `rpc`/`tidx` * upstream for any chain beyond the built-in public defaults. */ export const ChainId = z .pipe( z.union([ z.pipe( z.enum(['mainnet', 'testnet']), z.transform((alias) => chainIdAlias[alias]), ), z.pipe( z.coerce.number(), z.transform((chainId) => resolveChainId(chainId)), ), ]), z.number().check(z.int(), z.positive()), ) .check( z.describe( 'Which Tempo network to query. Use the alias `mainnet` or `testnet`, or a numeric chain id (mainnet is `4217`).', ), z.meta({ examples: [4217] }), ) /** * Optional `chainId` query parameter shared across endpoints. Accepts a chain * alias (`mainnet`/`testnet`) or a numeric chain id, normalizing both to a * numeric {@link ChainId}. */ export const ChainIdQuery = z // The default is documented in the description, not the JSON Schema `default` // keyword: zod drops `default`/`examples` from the `anyOf` this pipe produces // in input mode, and only `description` survives onto the generated query // parameter. The runtime fallback for an omitted `chainId` is the app's // configured `defaultChainId` (mainnet unless overridden), not a schema value. .optional(ChainId) .check( z.describe( 'Which Tempo network to query. Use the alias `mainnet` or `testnet`, or a numeric chain id. Defaults to `mainnet` when omitted.', ), z.meta({ examples: [4217] }), ) const booleanQueryBase = z.pipe( z.enum(['true', 'false']), z.transform((value) => value === 'true'), ) /** * Boolean query parameter. Query values arrive as the literal strings * `"true"`/`"false"`, validated strictly and transformed to a real boolean. * `z.boolean()` would reject the incoming string outright and * `z.coerce.boolean()` is unsafe (`Boolean("false") === true`). * * The generated JSON Schema is overridden to a plain `boolean` so the parameter * renders as a boolean — not a string — in the OpenAPI docs (and the generated * CLI coerces it), while the wire format and strict `"true"`/`"false"` * validation stay unchanged. The string `enum` that `z.enum` would emit is * dropped (`enum: undefined`): left in place it makes consumers — e.g. the * generated CLI — build a literal-`boolean` enum that then rejects the incoming * string. Wrap with `z.optional(...)` / `z._default(..., value)` and describe * with `.check(z.describe('…'))` at the call site — the override survives both. * Returns a fresh clone per call so each call site is a distinct schema. */ export function booleanQuery(): typeof booleanQueryBase { const cloned = booleanQueryBase.clone() z.globalRegistry.add(cloned, { ...z.globalRegistry.get(booleanQueryBase), enum: undefined, type: 'boolean', }) return cloned } /** One public API error detail. */ export const ErrorDetail = z .object({ message: z .string() .check( z.describe( 'A specific thing that went wrong, in plain language (e.g. why a field failed validation).', ), ), path: z .optional(z.array(z.union([z.string(), z.number()]))) .check( z.describe( 'Where the problem is, as a path into your request (e.g. `["query", "limit"]`). Present for validation errors.', ), ), }) .check(z.describe('A single problem with the request.')) z.globalRegistry.add(ErrorDetail, { ...z.globalRegistry.get(ErrorDetail), ref: 'ErrorDetail' }) /** * Builds the global error envelope, optionally enumerating `error.code` to a * fixed set of machine-readable codes. Pass the codes a given status can return * (e.g. `['token_not_found']` for a 404) so the reference and generated clients * narrow the code to a literal union instead of an open `string`. Omit `codes` * for the open-coded envelope. */ export function errorResponse(codes?: readonly [string, ...string[]]) { const code = (codes ? z.enum(codes) : z.string()).check( z.describe('A short, stable code you can branch on in your code (e.g. `token_not_found`).'), ) return z .object({ error: z .object({ code, details: z .optional(z.array(ErrorDetail)) .check( z.describe( 'A list of specific problems, when the error is about your request (e.g. invalid fields).', ), ), message: z.string().check(z.describe('A human-readable explanation of what went wrong.')), }) .check(z.describe('What went wrong.')), requestId: z .string() .check(z.describe('The id of this request — include it when contacting support.')), }) .check(z.describe('The standard shape returned for every error response.')) } /** Global error envelope with an open-coded `error.code`. */ export const ErrorResponse = errorResponse() /** Representative 32-byte hash used as the default OpenAPI example. */ const hashExample = '0x515801d7f9a5ac705e793e85904c9c69b3f1694b465cc8fb6ba3f0298dc82665' // TODO(ox/zod): replace with ox's `Hash` zod schema once ox ships a `zod` // entrypoint, so the wire format stays in lockstep with ox/viem. /** * Lowercase 0x-prefixed 32-byte hash. The OpenAPI example is attached to the * inner pre-transform schema (path/query params read examples from the pipe * input), so every hash path param surfaces one without per-route overrides. * Callers that need a different example per field build a fresh schema via * `hash(example)` instead of overriding through an outer `.check(z.meta(...))`. */ export const Hash = hash(hashExample) /** * Builds a {@link Hash}-shaped schema with the given OpenAPI example. Use when * a path/query param wants a different example than the shared {@link Hash} * default (e.g. a route that documents a specific real transaction). * * The example must be attached to the inner pre-transform schema because * path/query params read examples from the pipe input. */ export function hash(example: core_Hex.Hex) { return z .pipe( z .templateLiteral(['0x', z.string().check(z.regex(/^[0-9a-fA-F]{64}$/))]) .check(z.meta({ examples: [example] })), z.transform((value) => value.toLowerCase() as core_Hex.Hex), ) .check( z.describe( 'A 32-byte hash, `0x`-prefixed and returned in lowercase — for example a transaction or block hash.', ), ) } // TODO(ox/zod): replace with ox's `Hex` zod schema once ox ships a `zod` // entrypoint, so the wire format stays in lockstep with ox/viem. /** Lowercase 0x-prefixed hex byte data (may be empty, `0x`). */ export const Hex = z .pipe( z.string().check(z.regex(/^0x[0-9a-fA-F]*$/)), z.transform((value) => value.toLowerCase() as core_Hex.Hex), ) .check( z.describe( 'Raw binary data as a `0x`-prefixed lowercase hex string (may be empty, just `0x`).', ), z.meta({ examples: ['0xdeadbeef'] }), ) // TODO(ox/zod): replace with ox's `Quantity` zod schema once ox ships a `zod` // entrypoint, so the wire format stays in lockstep with ox/viem. /** Hex-encoded integer quantity (a `0x`-prefixed integer, e.g. `0x1a`). */ export const Quantity = z .templateLiteral(['0x', z.string().check(z.regex(/^[0-9a-fA-F]+$/))]) .check( z.describe('A whole number encoded as a `0x`-prefixed hexadecimal string (e.g. `0x1a` is 26).'), z.meta({ examples: ['0x1a'] }), ) /** Non-negative integer as a decimal string (preserves uint256 precision). */ export const DecimalString = z .string() .check( z.regex(/^\d+$/), z.describe( 'A non-negative whole number, given as a decimal string so very large token amounts keep full precision. Expressed in the smallest unit of the token (e.g. `1000000` is 1.00 of a 6-decimal stablecoin).', ), z.meta({ examples: ['1000000'] }), ) /** A self-contained token quantity in base units and human-readable form. */ export const TokenAmount = describe( z.object({ baseUnits: DecimalString.check(z.describe("The quantity in the token's smallest unit.")), currency: z .string() .check( z.describe('The monetary denomination of this quantity.'), z.meta({ examples: ['USD'] }), ), decimals: z .number() .check( z.int(), z.nonnegative(), z.describe('Decimal places used to convert `baseUnits` into `formatted`.'), z.meta({ examples: [6] }), ), formatted: z .string() .check( z.regex(/^\d+(\.\d+)?$/), z.describe('The quantity rendered in whole token units.'), z.meta({ examples: ['1'] }), ), }), 'A token quantity with its denomination and decimal representation.', ) /** * Minimum page size accepted across list endpoints. Floored at 5 because the * indexer rejects signature-decoded `Transfer` queries with `LIMIT < 5` * (HTTP 422); applying it globally keeps pagination uniform and removes the need * for per-endpoint clamping. */ export const minLimit = 5 /** Maximum number of rows returned per page. Larger requests are clamped to this value. */ export const maxLimit = 50 /** Page-size query parameter shared across list endpoints. */ export const Limit = z ._default( z.pipe( z.coerce.number().check(z.int(), z.gte(minLimit)), z.transform((value) => Math.min(value, maxLimit)), ), 10, ) .check( z.describe( 'How many items to return per page (5\u201350, default 10). Use `nextCursor` to fetch more.', ), z.meta({ examples: [10] }), ) /** Valuation currency query parameter shared across valuation-bearing endpoints. */ export const Denomination = z .optional( z.pipe( z.string().check(z.regex(/^[a-zA-Z]{3}$/)), z.transform((value) => value.toUpperCase()), ), ) .check( z.describe( 'Currency to denominate values in (case-insensitive, e.g. `AUD`). ' + 'Must be priced by the configured FX oracle.', ), z.meta({ examples: ['AUD'] }), ) /** Opaque keyset-pagination cursor query parameter shared across list endpoints. */ export const Cursor = z .optional(z.string()) .check( z.describe( 'Opaque keyset cursor from a previous response (`nextCursor`); pass it back verbatim to fetch the next page. Omit for the first (head) page.', ), z.meta({ examples: ['WzIzNDU2Nzg5LDBd'] }), ) /** * Maximum page window (`page × limit` rows) accepted by positional pagination. * Stays below the indexer's own 10k row ceiling so large offsets do not reach * the indexer. Deeper traversal must use cursors. */ export const maxPageWindow = 500 /** * Page-number query parameter (1-indexed) shared by list endpoints that * support shallow positional pagination alongside keyset cursors. Cursors * remain the canonical deep-traversal mode; the page lane exists for * page-numbered UIs that need random access into the head of a feed. */ export const Page = z .optional(z.coerce.number().check(z.int(), z.gte(1))) .check( z.describe( 'Page number, 1-indexed (positional pagination for page-numbered access; `page=1` is ' + 'the head page). Mutually exclusive with `cursor`, and `page × limit` must be at most ' + `${maxPageWindow} rows — use cursor pagination for deeper traversal. Pages are ` + 'positional, so rows arriving at the head of a live feed can shift page contents.', ), z.meta({ examples: [1] }), ) /** * Cross-field checks for list query schemas that accept both `cursor` and * `page`: the two are mutually exclusive, and the page window is bounded by * {@link maxPageWindow}. Spread into the query object's `.check(...)`. */ export function pageChecks() { type Query = { cursor?: string | undefined; limit: number; page?: number | undefined } return [ z.refine((query) => query.cursor === undefined || query.page === undefined, { error: '`cursor` and `page` are mutually exclusive; pass one or the other.', path: ['page'], }), z.refine((query) => (query.page ?? 1) * query.limit <= maxPageWindow, { error: `\`page × limit\` must be at most ${maxPageWindow} rows; use cursor pagination for deeper traversal.`, path: ['page'], }), ] as const } /** Opaque next-page cursor response field shared across list endpoints. */ export const NextCursor = z .nullable(z.string()) .check( z.describe( 'Pass this back as the `cursor` query parameter to fetch the next page. `null` once you have reached the end of the list.', ), z.meta({ examples: ['WzIzNDU2Nzg5LDBd'] }), ) /** * Upper bound on the matched-row count surfaced by `include=totalCount`. The * count is computed by a capped subquery (`… LIMIT countCap`) so it can't blow * the indexer's execution budget on the 10⁹-plus row tables; when the bound is * hit the count is a lower bound, signalled by `Meta.totalCountCapped`. Mirrors the * indexer's own 10k row ceiling and the capped-count conventions of comparable * APIs (Stripe Search's 10k `total_count`, * Elasticsearch `track_total_hits`, Shopify's `Count.precision`). */ export const countCap = 10_000 /** * Total matched-row count response field for list endpoints that support * `include=totalCount`. Exact when {@link TotalCountCapped} is `false`; a lower * bound ("at least this many") when `true`. Independent of pagination — * `nextCursor` remains the only end-of-list signal. */ export const TotalCount = z .number() .check( z.int(), z.nonnegative(), z.describe( 'Number of rows matching the query, exact when `totalCountCapped` is false and a lower ' + `bound (at least this many, computed up to ${countCap}) when \`totalCountCapped\` is ` + 'true. Independent of pagination: use `nextCursor` to page, not this count.', ), z.meta({ examples: [42] }), ) /** * Whether {@link TotalCount} hit the {@link countCap} bound. `true` means the * count is a lower bound ("at least `totalCount` rows match"); `false` means it * is exact. */ export const TotalCountCapped = z .boolean() .check( z.describe( 'Whether `totalCount` hit the count cap. When true, `totalCount` is a lower bound rather ' + 'than an exact total.', ), z.meta({ examples: [false] }), ) /** * Resource environment (`production`/`sandbox`) query parameter shared across * environment-scoped endpoints. Defaults to `production`. */ export const Environment = z ._default(z.enum(['production', 'sandbox']), 'production') .check(z.describe('Resource environment.'), z.meta({ examples: ['sandbox'] })) /** Sort-direction (`asc`/`desc`) query parameter shared across list endpoints. */ export const Order = z ._default(z.enum(['asc', 'desc']), 'desc') .check( z.describe('Sort order: `desc` for descending (the default), or `asc` for ascending.'), z.meta({ examples: ['desc'] }), ) /** * Optional inclusive block-number bound query parameter (`blockNumber.from` / * `blockNumber.to`). `noun` is the resource being listed (e.g. `transfers`) so * the description reads naturally across endpoints. */ export function blockNumberBound(noun: string, bound: 'from' | 'to') { const direction = bound === 'from' ? 'at or after' : 'at or before' return z .optional(z.coerce.number().check(z.int(), z.nonnegative())) .check( z.describe(`Only include ${noun} ${direction} this block number.`), z.meta({ examples: [bound === 'from' ? 23456789 : 23456999] }), ) } /** * Optional inclusive ISO-8601 timestamp bound query parameter (`timestamp.from` * / `timestamp.to`), normalized to UTC. `noun` is the listed resource. */ export function timestampBound(noun: string, bound: 'from' | 'to') { const direction = bound === 'from' ? 'at or after' : 'at or before' return z .optional( z.pipe( z.iso.datetime({ offset: true }), z.transform((value) => new Date(value).toISOString()), ), ) .check( z.describe(`Only include ${noun} ${direction} this ISO 8601 timestamp.`), z.meta({ examples: [bound === 'from' ? '2024-01-01T00:00:00Z' : '2024-12-31T23:59:59Z'], }), ) } /** * Builds the schema for a comma-separated opt-in query parameter (e.g. * `?include=token,totalCount`), parsed into a validated array of the given enum's * members. Absent or empty values yield `[]`. Centralizes the splitting, * trimming, and OpenAPI array-enum metadata that every list endpoint otherwise * repeats. * * A repeated parameter (`?include=token&include=totalCount`) arrives from the * validator as a `string[]`, so the input accepts both forms and each entry is * still comma-split, so the two styles compose freely. * * Pass the endpoint's local `Include` enum so its members drive the parsed type * and documentation, plus the parameter description. */ export function includeQuery>>( member: z.ZodMiniEnum, description: string, ) { return z .pipe( z.pipe( // `optional(unknown)` keeps the parameter optional in object inputs // while staying opaque to JSON-schema generation — a structural // `string | string[]` union would leak a contradictory `anyOf` next to // the explicit `meta` below, which is the documented contract. z.optional(z.unknown()), z.transform((value) => { const parts = value === undefined ? [] : Array.isArray(value) ? value : [value] return parts .flatMap((entry) => String(entry).split(',')) .map((entry) => entry.trim()) .filter(Boolean) }), ), z.array(member), ) .check( z.meta({ type: 'array', items: { type: 'string', enum: member.options }, description, examples: [member.options], }), ) } /** * Ready-made `include` query parameter for list endpoints whose only optional * resource is the capped total count (`?include=totalCount`). Endpoints that * embed additional resources define their own enum that also lists `totalCount` * (e.g. transfers' `memo,token,totalCount`). */ export const totalCountInclude = includeQuery( z.enum(['totalCount']).check(z.describe('Optional resources to embed via `include`.')), 'Comma-separated optional resources to embed, e.g. `totalCount`.', ) /** * Response-wide `meta` object for list endpoints whose only embeddable resource * is the capped total count. Present only when `include=totalCount` was * requested; omitted otherwise. Endpoints that embed additional response-wide * resources alongside the count define their own `Meta` that also carries * {@link TotalCount}/{@link TotalCountCapped}. */ export const CountMeta = z .object({ totalCountCapped: TotalCountCapped, totalCount: TotalCount }) .check(z.describe('Response-wide resources embedded on demand via `include`.'))