import { Hono, type Context } from 'hono' import { AbiEvent, AbiParameters } from 'ox' import { type Address, ContractFunctionExecutionError } from 'viem' import { Abis } from 'viem/tempo' import * as z from 'zod/mini' import * as Assets from '../../../Assets.js' import type * as App from '../../../App.js' import * as Auth from '../../../internal/Auth.js' import * as Cache from '../../../internal/Cache.js' import * as Cursor from '../../../internal/Cursor.js' import * as OpenApi from '../../../internal/OpenApi.js' import * as Path from '../../../internal/Path.js' import * as Response from '../../../internal/Response.js' import * as Schema from '../../../internal/Schema.js' import * as Store from '../../../internal/Store.js' import * as Tidx from '../../../internal/Tidx.js' import * as Timing from '../../../internal/Timing.js' import * as Ttl from '../../../internal/Ttl.js' import * as Value from '../../../internal/Value.js' import * as VerifiedTokens from '../../../internal/VerifiedTokens.js' import * as Transactions from './transactions.js' /** Mainnet chain id used by the coherent token OpenAPI example. */ export const tokenExampleChainId = 4217 /** Coherent mainnet token metadata used by generated OpenAPI examples. */ export const tokenExample = { address: '0x20c000000000000000000000b9537d11c60e8b50', currency: 'USD', decimals: 6, id: '0x20c000000000000000000000b9537d11c60e8b50', logoUri: Assets.url({ chainId: tokenExampleChainId, origin: 'https://api.tempo.xyz', path: 'icons/0x20c000000000000000000000b9537d11c60e8b50', }), name: 'Bridged USDC (Stargate)', symbol: 'USDC.e', totalSupply: '10850791186630', verified: true, } as const const tokenCreatedSignature = 'event TokenCreated(address indexed token, string name, string symbol, string currency, address quoteToken, address admin, bytes32 salt)' // Topic0 of the `TokenCreated` event // (= 0x44f7b8011db3e3647a530b4ff635726de5fafc8fa8ad10f0f31c0eb9dd52fc65). The // deployed indexer's `tokencreated` event CTE cannot serve the `quoteToken` / // `admin` columns (selecting them returns `db error`), so the created-extras // lookup reads the raw `logs` table by topic0 and ABI-decodes the data blob // instead. const tokenCreatedTopic = AbiEvent.getSelector(tokenCreatedSignature) // Non-indexed `TokenCreated` data layout, in event-parameter order: // `[name, symbol, currency, quoteToken, admin, salt]`. const tokenCreatedData = AbiParameters.from([ 'string', 'string', 'string', 'address', 'address', 'bytes32', ]) // Topic0 of `Transfer(address,address,uint256)`; lifetime transfer statistics // aggregate the raw ClickHouse `logs` stream by emitting token contract. const transferTopic = AbiEvent.getSelector('event Transfer(address,address,uint256)') /** Zod schemas owned by the token handlers. */ export namespace schema { /** Schemas for the getToken operation. */ export namespace getToken { /** Path parameters for token metadata requests. */ export const Params = z .object({ token: Schema.tokenAddress(tokenExample.address).check( z.describe('The TIP-20 token contract address on Tempo.'), ), }) .check(z.describe('Path parameters for looking up one token’s metadata.')) /** Optional expensive fields that callers opt into via `include`. */ export const Include = z .enum(['admin', 'createdAt', 'holderCount', 'quoteToken', 'transferStats']) .check( z.describe('Extra token details to calculate only when you request them with `include`.'), ) /** * Parses a comma-separated `include` query value into a list of optional * fields. Expensive computations (the `createdAt`/`admin`/`quoteToken` * indexer lookups, holder counts, and transfer statistics) are only run * when explicitly requested, keeping the base response fast. */ export const includeQuery = Schema.includeQuery( Include, 'Comma-separated extra token details to include, such as `admin,createdAt,holderCount,quoteToken,transferStats`.', ) /** Query parameters for token metadata requests. */ export const Query = z .object({ chainId: Schema.ChainIdQuery, include: includeQuery, }) .check(z.describe('Query parameters for looking up one token’s metadata.')) /** TIP-20 token metadata returned by token endpoints. */ export const Response = OpenApi.component( Schema.describe( z.object({ address: Schema.tokenAddress(tokenExample.address).check( z.describe('The TIP-20 token contract address on Tempo.'), ), admin: z .optional(Schema.Address) .check( z.describe( 'Token admin from the onchain `TokenCreated` event, present when requested ' + 'via `include=admin` and indexed data is available.', ), ), currency: z .string() .check( z.describe( 'The currency label for this token, such as `USD` for USD-denominated stablecoins.', ), z.meta({ examples: [tokenExample.currency] }), ), createdAt: z .optional(z.iso.datetime()) .check( z.describe( 'Token creation timestamp, present when requested via `include=createdAt` ' + 'and indexed data is available.', ), z.meta({ examples: ['2024-01-01T00:00:00Z'] }), ), decimals: z .number() .check( z.int(), z.nonnegative(), z.describe( 'The number of decimal places the token uses; Tempo stablecoins typically use 6.', ), z.meta({ examples: [tokenExample.decimals] }), ), holderCount: z .optional(z.number().check(z.int(), z.nonnegative())) .check( z.describe( 'The number of accounts that currently hold a positive balance of this token, when indexed holder data is available.', ), z.meta({ examples: [1234] }), ), id: z .string() .check( z.describe('A stable resource ID for this token, equal to its contract address.'), z.meta({ examples: [tokenExample.id] }), ), logoUri: z.optional(z.string()).check( z.describe('A URL for the token’s logo image, when one is available.'), z.meta({ examples: [tokenExample.logoUri], }), ), name: z .string() .check( z.describe('The token’s human-readable name.'), z.meta({ examples: [tokenExample.name] }), ), quoteToken: z .optional(Schema.Address) .check( z.describe( 'Quote token from the onchain `TokenCreated` event, present when requested ' + 'via `include=quoteToken` and indexed data is available.', ), ), symbol: z .string() .check( z.describe('The short ticker symbol wallets and apps show for this token.'), z.meta({ examples: [tokenExample.symbol] }), ), totalSupply: z .optional(z.string().check(z.regex(/^\d+$/))) .check( z.describe( 'The token’s total supply as a decimal string in the smallest unit, so large values keep full precision.', ), z.meta({ examples: [tokenExample.totalSupply] }), ), transferStats: z .optional( z.object({ count: z .number() .check( z.int(), z.nonnegative(), z.describe('The total number of `Transfer` events emitted by this token.'), z.meta({ examples: [12345] }), ), firstAt: z .nullable(z.iso.datetime()) .check( z.describe( 'The time of this token’s first `Transfer` event, or `null` if no transfers exist.', ), z.meta({ examples: ['2024-01-01T00:00:00Z'] }), ), lastAt: z .nullable(z.iso.datetime()) .check( z.describe( 'The time of this token’s most recent `Transfer` event, or `null` if no transfers exist.', ), z.meta({ examples: ['2024-06-01T12:00:00Z'] }), ), }), ) .check( z.describe( 'Lifetime `Transfer` event statistics, present when requested via ' + '`include=transferStats` and indexed data is available.', ), ), verified: z .boolean() .check( z.describe('Whether this token is in Tempo’s curated verified token list.'), z.meta({ examples: [tokenExample.verified] }), ), }), 'Token metadata for a TIP-20 token, including name, symbol, decimals, supply, and optional indexed details.', ), 'Token', ) } /** * Trimmed TIP-20 token reference embedded in list/feed responses via * `include=token`. Carries identity and display fields only; supply-level and * temporal facts (`totalSupply`, `holderCount`, `createdAt`) belong on the * token detail endpoint, not in feed rows. */ export const Token = OpenApi.component( Schema.describe( z.pick(getToken.Response, { address: true, currency: true, decimals: true, id: true, logoUri: true, name: true, symbol: true, verified: true, }), 'A compact TIP-20 token reference with identity and display fields only.', ), 'TokenReference', ) /** RPC token metadata embedded in resources, with optional curated fields. */ export const TokenReference = OpenApi.component( Schema.describe( z.partial(z.omit(Token, { id: true }), { logoUri: true, verified: true }), 'A compact TIP-20 token reference with RPC metadata and optional enrichments.', ), 'RpcTokenReference', ) /** Schemas for the getTokenBySymbol operation. */ export namespace getTokenBySymbol { /** Hono route pattern for verified token symbols. */ // Symbols must start with a letter so this route cannot claim address-shaped // paths before the address validator gets a chance to report token errors. export const routePattern = '[A-Za-z][A-Za-z0-9._]{0,63}' const pattern = new RegExp(`^${routePattern}$`) /** Path parameters for token symbol metadata requests. */ export const Params = z .object({ symbol: z.string().check( z.regex(pattern), z.describe('The symbol of a token in Tempo’s verified token list.'), z.meta({ examples: ['USDC.e'], }), ), }) .check(z.describe('Path parameters for looking up a verified token by symbol.')) } /** Schemas for the getTokenLogo operation. */ export namespace getTokenLogo { /** Path parameters for token logo image requests. */ export const Params = z .object({ token: Schema.TokenAddress.check(z.describe('The TIP-20 token contract address on Tempo.')), }) .check(z.describe('Path parameters for fetching a token logo image.')) /** Query parameters for token logo image requests. */ export const Query = z .object({ chainId: Schema.ChainIdQuery, }) .check(z.describe('Query parameters for fetching a token logo image.')) } /** Schemas for the getTokens operation. */ export namespace getTokens { /** Query parameters for token list requests. */ export const Query = z .strictObject({ addresses: z .optional( z.pipe( // Bare-transform input stage, opaque to JSON-schema generation // (the `meta` below documents the parameter): accepts the comma // form and the repeated form (`?addresses=a&addresses=b`), which // the validator delivers as a `string[]`. z.transform((value) => (Array.isArray(value) ? value : [value]) .flatMap((entry) => String(entry).split(',')) .map((entry) => entry.trim()) .filter(Boolean), ), z.array(Schema.TokenAddress).check(z.maxLength(50)), ), ) .check( z.meta({ type: 'array', items: { type: 'string' }, examples: [['0x20c0000000000000000000008f5425160ebe5525']], }), z.describe( 'Comma-separated token contract addresses to fetch (max 50). Returns a ' + 'single page in input order (unresolvable addresses omitted); `cursor`, ' + '`page`, and `order` are inapplicable. Combines with `include`; ' + '`currency` and `verified` further filter the resolved set.', ), ), chainId: Schema.ChainIdQuery, currency: z .optional(z.string()) .check( z.meta({ examples: VerifiedTokens.currencies }), z.describe( 'Only include tokens denominated in this currency (e.g. `USD`). ' + 'Case-insensitive. Matched against the `currency` field of the onchain ' + '`TokenCreated` event, so any string a token deployer wrote is acceptable; ' + 'the listed examples are the well-known curated currencies.', ), ), cursor: Schema.Cursor, include: getToken.includeQuery, limit: Schema.Limit, order: Schema.Order, page: Schema.Page, verified: z .optional(Schema.booleanQuery()) .check( z.describe( 'When `true`, only return tokens in the curated verified list. ' + '`currency`, `include`, and the pagination parameters (`limit`, ' + '`page`, `cursor`, `order`) all apply; the list is paginated ' + 'positionally over its canonical order (`order=asc` reverses it).', ), z.meta({ examples: [true] }), ), }) .check(...Schema.pageChecks(), z.describe('Query parameters for listing TIP-20 tokens.')) /** Page of TIP-20 tokens. */ export const Response = OpenApi.component( z .object({ data: z.array(getToken.Response).check(z.describe('The tokens in this page.')), nextCursor: Schema.NextCursor, }) .check(z.describe('A page of TIP-20 tokens on Tempo.')), 'TokenList', ) } /** Schemas for the getTokenHolders operation. */ export namespace getTokenHolders { /** Path parameters for token holder requests. */ export const Params = z .object({ token: Schema.TokenAddress.check(z.describe('The TIP-20 token contract address on Tempo.')), }) .check(z.describe('Path parameters for listing a token’s holders.')) /** Optional related resources that callers opt into via `include`. */ export const Include = z .enum(['token', 'totalCount']) .check(z.describe('Related resources to include only when you request them with `include`.')) /** * Parses a comma-separated `include` query value into a list of optional * resources to embed. Extra lookups (token metadata, the holder total) only * run when explicitly requested, keeping the base holder page fast. */ export const includeQuery = Schema.includeQuery( Include, 'Comma-separated related resources to include, such as `token,totalCount`.', ) /** Query parameters for token holder requests. */ export const Query = z .strictObject({ chainId: Schema.ChainIdQuery, cursor: Schema.Cursor, include: includeQuery, limit: Schema.Limit, page: Schema.Page, }) .check(...Schema.pageChecks(), z.describe('Query parameters for listing a token’s holders.')) /** A single TIP-20 token holder. */ export const Holder = OpenApi.component( z .object({ address: Schema.Address.check(z.describe('The account address that holds this token.')), balance: 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.', ), z.meta({ examples: ['1000000'] }), ), id: z .string() .check( z.describe('A stable resource ID for this holder, equal to the holder address.'), z.meta({ examples: ['0xbe058e1c4df8a4366a387bf595b284246a93039e'] }), ), }) .check(z.describe('One account holding a positive balance of this TIP-20 token.')), 'TokenHolder', ) /** Resources embedded on demand via `include`. */ export const Meta = OpenApi.component( z .object({ totalCountCapped: z .optional(Schema.TotalCountCapped) .check( z.describe( 'Whether `totalCount` reached the API count cap; holder counts are exact here.', ), ), token: z .optional(Token) .check( z.describe('A compact token reference, returned when you request `include=token`.'), ), totalCount: z .optional(Schema.TotalCount) .check( z.describe( 'The total number of token holders, returned when you request `include=totalCount`.', ), ), }) .check(z.describe('Extra resources included because you requested them with `include`.')), 'TokenHolderListMeta', ) /** Page of TIP-20 token holders, ordered by balance descending. */ export const Response = OpenApi.component( z .object({ data: z .array(Holder) .check( z.describe('The holders in this page, ordered by balance from highest to lowest.'), ), meta: z.optional(Meta).check(z.describe('Extra resources you requested with `include`.')), nextCursor: Schema.NextCursor, }) .check(z.describe('A page of accounts holding this TIP-20 token.')), 'TokenHolderList', ) } /** Schemas for the getTokenTransactions operation. */ export namespace getTokenTransactions { /** Path parameters for token transaction requests. */ export const Params = z .object({ token: Schema.TokenAddress.check(z.describe('The TIP-20 token contract address on Tempo.')), }) .check(z.describe('Path parameters for listing transactions involving this token contract.')) /** Optional scoped-token resources and fee-token fields requested via `include`. */ export const Include = z .enum(['feeToken.logoUri', 'feeToken.verified', 'token', 'totalCount']) .check(z.describe('Related resources to include only when you request them with `include`.')) /** * Parses a comma-separated `include` query value into a list of optional * resources to embed. Extra lookups only run when explicitly requested, * keeping the base transaction page fast. */ export const includeQuery = Schema.includeQuery( Include, 'Comma-separated related resources to include, such as `feeToken.logoUri,feeToken.verified,token,totalCount`.', ) /** Query parameters for token transaction requests. */ export const Query = z .strictObject({ 'blockNumber.from': Schema.blockNumberBound('transactions', 'from'), 'blockNumber.to': Schema.blockNumberBound('transactions', 'to'), chainId: Schema.ChainIdQuery, cursor: Schema.Cursor, feePayer: z .optional(Schema.Address) .check(z.describe('Only include transactions where this account paid the fee.')), feeToken: z .optional(Schema.TokenAddress) .check( z.describe( 'Only include transactions whose fee was paid in this token. On Tempo, fees are paid in USD stablecoins instead of a separate volatile gas token.', ), ), include: includeQuery, limit: Schema.Limit, order: Schema.Order, page: Schema.Page, 'timestamp.from': Schema.timestampBound('transactions', 'from'), 'timestamp.to': Schema.timestampBound('transactions', 'to'), }) .check( ...Schema.pageChecks(), z.describe('Query parameters for listing transactions involving this token contract.'), ) /** Resources embedded on demand via `include`. */ export const Meta = OpenApi.component( z .object({ totalCountCapped: z .optional(Schema.TotalCountCapped) .check( z.describe( 'Whether `totalCount` reached the API count cap; when `true`, treat the count as a lower bound.', ), ), token: z .optional(Token) .check( z.describe('A compact token reference, returned when you request `include=token`.'), ), totalCount: z .optional(Schema.TotalCount) .check( z.describe( 'The capped total number of matching transactions, returned when you request `include=totalCount`.', ), ), }) .check(z.describe('Extra resources included because you requested them with `include`.')), 'TokenTransactionListMeta', ) /** * Page of transactions touching the token contract. Each row is the * humanized transaction shape returned by `GET /transactions/:transactionHash`; * the optional `meta.token` embed is the token being scoped to. * * The `data` schema is wrapped in `z.lazy` because `transactions.ts` * already imports `Tokens.schema` at module init; a direct reference * here would close the import cycle and crash during evaluation. */ export const Response = OpenApi.component( z .object({ data: z .array(z.lazy(() => Transactions.schema.getTransaction.Response)) .check(z.describe('The transactions in this page.')), meta: z.optional(Meta).check(z.describe('Extra resources you requested with `include`.')), nextCursor: Schema.NextCursor, }) .check(z.describe('A page of transactions that interact with this token contract.')), 'TokenTransactionList', ) } } /** Creates token handlers. */ export function tokens() { return new Hono() .get( '/v1/tokens', Auth.policy({ apiKey: { scopes: ['data:read'] }, mpp: true, public: true }), OpenApi.validate('query', schema.getTokens.Query, { code: 'query_invalid', message: 'Invalid query parameters', }), OpenApi.describeRoute({ description: 'Lists TIP-20 tokens on Tempo. TIP-20 is Tempo’s payments-focused token standard and a superset of ERC-20.', operationId: 'getTokens', responses: OpenApi.responses({ errors: { 400: { codes: ['chain_id_invalid', 'chain_id_unsupported', 'query_invalid'] }, }, success: { description: 'A page of TIP-20 tokens.', schema: schema.getTokens.Response }, }), summary: 'List tokens', tags: ['Tokens'], }), Cache.response({ cacheControl: Cache.policies.metadata, name: 'tempo-api:tokens:v1', // Verified pages are served from the curated snapshot, not the indexer, // so their cached entry must depend on the snapshot version — otherwise // a publish (create/patch/remove/replace) or reseed never invalidates // the page and a stale list is served indefinitely. Mirrors the // address-balances route. Non-verified pages stay URL-keyed. key: async (c) => { const base = Cache.urlKey(c, schema.getTokens.Query) const query = schema.getTokens.Query.parse( Object.fromEntries(new URL(c.req.url).searchParams), ) if (!query.verified) return base const chainId = query.chainId ?? c.get('chainId') const { version } = await VerifiedTokens.snapshot(c, chainId) return `${base}:v:${version}` }, }), async (c) => { if (Auth.narrowAccess) return Auth.paidAccessError(c) if (OpenApi.narrowValidation) return OpenApi.validationError(c, { code: 'query_invalid', message: 'Invalid query parameters', }) const query = c.req.valid('query') try { const chainId = query.chainId ?? c.get('chainId') return c.json( Response.validated( schema.getTokens.Response, await getTokens(c, { addresses: query.addresses, chainId, currency: query.currency, cursor: query.cursor, include: query.include, limit: query.limit, order: query.order, page: query.page, verified: query.verified, }), ), 200, ) } catch (cause) { return Response.upstream(c, cause, { page: query.page }) } }, ) .get( `/v1/tokens/:symbol{${schema.getTokenBySymbol.routePattern}}`, Auth.policy({ apiKey: { scopes: ['data:read'] }, mpp: true, public: true }), OpenApi.validate('param', schema.getTokenBySymbol.Params, { code: 'symbol_invalid', message: 'Invalid token symbol', }), OpenApi.validate('query', schema.getToken.Query, { code: 'query_invalid', message: 'Invalid query parameters', }), OpenApi.describeRoute({ description: 'Returns token metadata for a verified TIP-20 token using its symbol, such as `USDC`.', operationId: 'getTokenBySymbol', responses: OpenApi.responses({ errors: { 400: { codes: [ 'chain_id_invalid', 'chain_id_unsupported', 'query_invalid', 'symbol_invalid', ], }, 404: { description: 'No token was found for this address or symbol.', codes: ['token_not_found'], }, 502: 'An upstream RPC or indexer request failed while resolving this token.', }, success: { description: 'Metadata for one TIP-20 token.', example: tokenExample, schema: schema.getToken.Response, }, }), summary: 'Get token by symbol', tags: ['Tokens'], }), Cache.response({ cacheControl: Cache.policies.metadata, name: 'tempo-api:tokens:v1', key: (c) => Cache.urlKey(c, schema.getToken.Query), }), async (c) => { if (Auth.narrowAccess) return Auth.paidAccessError(c) if (OpenApi.narrowValidation) return OpenApi.validationError(c, { code: 'symbol_invalid', message: 'Invalid request parameters', }) if (OpenApi.narrowValidation) return OpenApi.validationError(c, { code: 'query_invalid', message: 'Invalid request parameters', }) const { symbol } = c.req.valid('param') const query = c.req.valid('query') const chainId = query.chainId ?? c.get('chainId') try { const snapshot = await VerifiedTokens.snapshot(c, chainId) const token = snapshot.bySymbol.get(symbol.toLowerCase()) if (token) return getToken(c, { address: token.address, chainId, include: query.include, snapshot, }) return Response.error(c, { code: 'token_not_found', message: 'Token not found', status: 404, }) } catch (cause) { return Response.upstream(c, cause) } }, ) .get( '/v1/tokens/:token', Auth.policy({ apiKey: { scopes: ['data:read'] }, mpp: true, public: true }), OpenApi.validate('param', schema.getToken.Params, { code: 'token_invalid', message: 'Invalid token address', }), OpenApi.validate('query', schema.getToken.Query, { code: 'query_invalid', message: 'Invalid query parameters', }), OpenApi.describeRoute({ description: 'Returns token metadata for a TIP-20 contract address, whether or not the token is in the verified list.', operationId: 'getToken', responses: OpenApi.responses({ errors: { 400: { codes: ['chain_id_invalid', 'chain_id_unsupported', 'query_invalid', 'token_invalid'], }, 404: { description: 'No token was found for this address or symbol.', codes: ['token_not_found'], }, 502: 'An upstream RPC or indexer request failed while resolving this token.', }, success: { description: 'Metadata for one TIP-20 token.', example: tokenExample, schema: schema.getToken.Response, }, }), summary: 'Get token by address', tags: ['Tokens'], }), Cache.response({ cacheControl: Cache.policies.metadata, name: 'tempo-api:tokens:v1', key: (c) => Cache.urlKey(c, schema.getToken.Query), }), async (c) => { if (Auth.narrowAccess) return Auth.paidAccessError(c) if (OpenApi.narrowValidation) return OpenApi.validationError(c, { code: 'token_invalid', message: 'Invalid request parameters', }) if (OpenApi.narrowValidation) return OpenApi.validationError(c, { code: 'query_invalid', message: 'Invalid request parameters', }) const { token: address } = c.req.valid('param') const query = c.req.valid('query') const chainId = query.chainId ?? c.get('chainId') return getToken(c, { address, chainId, include: query.include }) }, ) .get( '/v1/tokens/:token/logo', Auth.policy({ apiKey: { scopes: ['data:read'] }, mpp: true, public: true }), OpenApi.validate('param', schema.getTokenLogo.Params, { code: 'token_invalid', message: 'Invalid token address', }), OpenApi.validate('query', schema.getTokenLogo.Query, { code: 'query_invalid', message: 'Invalid query parameters', }), OpenApi.describeRoute({ description: 'Returns the logo image for a TIP-20 token, using Tempo’s curated asset when available.', operationId: 'getTokenLogo', responses: { 200: { content: { 'image/*': {} }, description: 'The token logo image.', headers: OpenApi.successHeaders, }, 400: OpenApi.standardError(400, 'The request parameters were invalid.', [ 'api_key_malformed', 'chain_id_invalid', 'chain_id_unsupported', 'query_invalid', 'token_invalid', ]), 401: OpenApi.standardError(401, 'The API key is missing or invalid.'), 403: OpenApi.standardError(403, 'The API key cannot access this resource.'), 404: OpenApi.standardError(404, 'No logo image was found for this token.', [ 'token_logo_not_found', ]), 429: OpenApi.standardError(429, 'Too many requests; wait and try again.'), 500: OpenApi.standardError(500, 'The API encountered an internal error.'), 502: OpenApi.standardError( 502, 'An upstream RPC request failed while fetching the logo.', ), }, summary: 'Get token logo', tags: ['Tokens'], }), // Publish the `asset` (forever, public) policy so the pre-auth edge cache // serves logo bytes to every caller without re-running the handler — the // bytes carry no per-principal data. `vary` is `Accept-Encoding` only so a // single shared entry serves authenticated and anonymous callers alike. Cache.response({ cacheControl: Cache.policies.asset, name: 'tempo-api:tokens:v1', key: (c) => Cache.urlKey(c, schema.getTokenLogo.Query), vary: ['Accept-Encoding'], }), async (c) => { if (Auth.narrowAccess) return Auth.paidAccessError(c) if (OpenApi.narrowValidation) return OpenApi.validationError(c, { code: 'token_invalid', message: 'Invalid request parameters', }) if (OpenApi.narrowValidation) return OpenApi.validationError(c, { code: 'query_invalid', message: 'Invalid request parameters', }) const { token: address } = c.req.valid('param') const query = c.req.valid('query') const chainId = query.chainId ?? c.get('chainId') try { const logo = await Timing.time(c, 'token_logo', () => fetchTokenLogo(c, { address, chainId }), ) if (!logo) return Response.error(c, { code: 'token_logo_not_found', message: 'Token logo not found', status: 404, }) // `Cache.response` (above) stamps the `asset` Cache-Control / Vary; // only the content type is set here. return c.body(logo.body, 200, { 'Content-Type': logo.contentType }) } catch (cause) { // A token whose on-chain metadata read reverts (unregistered TIP-20) // has no logo to serve — map it to the route's `404`, not a `502`, // mirroring `getToken`. Only genuine upstream failures surface as 502. if (isTokenNotFound(cause)) return Response.error(c, { code: 'token_logo_not_found', message: 'Token logo not found', status: 404, }) return Response.upstream(c, cause) } }, ) .get( '/v1/tokens/:token/holders', Auth.policy({ apiKey: { scopes: ['data:read'] }, mpp: true, public: true }), OpenApi.validate('param', schema.getTokenHolders.Params, { code: 'token_invalid', message: 'Invalid token address', }), OpenApi.validate('query', schema.getTokenHolders.Query, { code: 'query_invalid', message: 'Invalid query parameters', }), OpenApi.describeRoute({ description: 'Lists the accounts that hold a TIP-20 token, ordered from largest to smallest balance.', operationId: 'getTokenHolders', responses: OpenApi.responses({ errors: { 400: { codes: ['chain_id_invalid', 'chain_id_unsupported', 'query_invalid', 'token_invalid'], }, 502: 'The upstream indexer could not serve this request.', }, success: { description: 'A page of token holders.', schema: schema.getTokenHolders.Response, }, }), summary: 'List token holders', tags: ['Tokens'], }), Cache.response({ cacheControl: Cache.policies.state, name: 'tempo-api:tokens:v1', key: (c) => Cache.urlKey(c, schema.getTokenHolders.Query), }), async (c) => { if (Auth.narrowAccess) return Auth.paidAccessError(c) if (OpenApi.narrowValidation) return OpenApi.validationError(c, { code: 'token_invalid', message: 'Invalid request parameters', }) if (OpenApi.narrowValidation) return OpenApi.validationError(c, { code: 'query_invalid', message: 'Invalid request parameters', }) const { token: address } = c.req.valid('param') const query = c.req.valid('query') try { const chainId = query.chainId ?? c.get('chainId') // Resolve the holder page and (opt-in) token metadata concurrently so // the token lookup is hidden behind the slower holder aggregation // rather than adding to it. const includeToken = query.include.includes('token') // `totalCount` is the holder total, read exactly from the // pre-aggregated `token_holder_counts` view (not a capped scan), so // `totalCountCapped` is always false. Best-effort: `getHolderCount` returns // null when unavailable, in which case the count is omitted. const includeTotalCount = query.include.includes('totalCount') const [holders, token, holderCount] = await Promise.all([ getTokenHolders(c, { address, chainId, cursor: query.cursor, limit: query.limit, page: query.page, }), includeToken ? Timing.time(c, 'token', () => resolveToken(c, { address, chainId })) : Promise.resolve(undefined), includeTotalCount ? Timing.time(c, 'token_holders_count', () => getHolderCount(c, { address, chainId })) : Promise.resolve(null), ]) // Embedded resources requested via `include` live under `meta`, // separate from the holder page (`data`) and pagination fields. `meta` // is omitted entirely when nothing response-wide was requested. const meta = { ...(token ? { token } : {}), ...(holderCount !== null ? { totalCountCapped: false, totalCount: holderCount } : {}), } return c.json( Response.validated(schema.getTokenHolders.Response, { ...holders, meta: Object.keys(meta).length > 0 ? meta : undefined, }), 200, ) } catch (cause) { return Response.upstream(c, cause, { page: query.page }) } }, ) .get( '/v1/tokens/:token/transactions', Auth.policy({ apiKey: { scopes: ['data:read'] }, mpp: true, public: true }), OpenApi.validate('param', schema.getTokenTransactions.Params, { code: 'token_invalid', message: 'Invalid token address', }), OpenApi.validate('query', schema.getTokenTransactions.Query, { code: 'query_invalid', message: 'Invalid query parameters', }), OpenApi.describeRoute({ description: 'Lists transactions that interacted with this token contract.', operationId: 'getTokenTransactions', responses: OpenApi.responses({ errors: { 400: { codes: ['chain_id_invalid', 'chain_id_unsupported', 'query_invalid', 'token_invalid'], }, 502: 'The upstream indexer could not serve this request.', }, success: { description: 'A page of transactions involving this token contract.', schema: schema.getTokenTransactions.Response, }, }), summary: 'List token transactions', tags: ['Transactions'], }), Cache.response({ cacheControl: Cache.policies.feed, name: 'tempo-api:tokens:v1', key: (c) => Cache.urlKey(c, schema.getTokenTransactions.Query), }), async (c) => { if (Auth.narrowAccess) return Auth.paidAccessError(c) if (OpenApi.narrowValidation) return OpenApi.validationError(c, { code: 'token_invalid', message: 'Invalid request parameters', }) if (OpenApi.narrowValidation) return OpenApi.validationError(c, { code: 'query_invalid', message: 'Invalid request parameters', }) const { token: address } = c.req.valid('param') const query = c.req.valid('query') // Cursor pages are anchored below the head. Cache chain data as immutable, // but refresh requested token metadata on the metadata schedule. if (query.cursor !== undefined) Cache.setPolicy( c, query.include.some( (resource) => resource === 'token' || resource.startsWith('feeToken.'), ) ? Cache.policies.metadata : Cache.policies.immutable, ) try { const chainId = query.chainId ?? c.get('chainId') // Resolve the transaction page and scoped token concurrently so the // token lookup is hidden behind the transaction query. const includeToken = query.include.includes('token') // `totalCount` is opt-in; count transactions touching this token // (address-scoped to the contract) under the same filters as the // page. Best-effort: a count failure omits the count from `meta`. const countPromise = query.include.includes('totalCount') ? Transactions.countTransactions(c, { address, chainId, feePayer: query.feePayer, feeToken: query.feeToken, fromBlock: query['blockNumber.from'], fromTimestamp: query['timestamp.from'], toBlock: query['blockNumber.to'], toTimestamp: query['timestamp.to'], }).catch(() => undefined) : undefined const [transactions, token, count] = await Promise.all([ Transactions.listTransactions(c, { address, chainId, cursor: query.cursor, feePayer: query.feePayer, feeToken: query.feeToken, fromBlock: query['blockNumber.from'], fromTimestamp: query['timestamp.from'], include: query.include.filter( (resource): resource is 'feeToken.logoUri' | 'feeToken.verified' => resource === 'feeToken.logoUri' || resource === 'feeToken.verified', ), limit: query.limit, order: query.order, page: query.page, toBlock: query['blockNumber.to'], toTimestamp: query['timestamp.to'], }), includeToken ? Timing.time(c, 'token', () => resolveToken(c, { address, chainId })) : Promise.resolve(undefined), countPromise, ]) const meta = { ...(token ? { token } : {}), ...(count ? { totalCountCapped: count.totalCountCapped, totalCount: count.totalCount } : {}), } return c.json( Response.validated(schema.getTokenTransactions.Response, { ...transactions, meta: Object.keys(meta).length > 0 ? meta : undefined, }), 200, ) } catch (cause) { return Response.upstream(c, cause, { page: query.page }) } }, ) } async function getToken(c: Context, options: getToken.Options) { return Timing.time(c, 'token', async () => { try { return c.json(await resolveToken(c, options), 200) } catch (cause) { if (isTokenNotFound(cause)) return Response.error(c, { code: 'token_not_found', message: 'Token not found', status: 404, }) return Response.upstream(c, cause) } }) } declare namespace getToken { type Options = { address: z.output chainId: z.output createdAtByAddress?: Record | undefined /** Pre-fetched `TokenCreated` extras (`admin`/`quoteToken`) for page callers. */ createdByAddress?: Record | undefined /** Pre-fetched holder counts for page callers. */ holderCountByAddress?: Record | undefined include?: readonly z.output[] | undefined snapshot?: VerifiedTokens.Snapshot | undefined /** Pre-fetched transfer statistics for page callers. */ transferStatsByAddress?: Record | undefined } } /** * Resolves the token metadata object. Throws on upstream failure / not found so * callers can map the error (e.g. `getToken` → 404/502). Shared by `getToken`, * the holder page's `include=token`, and the per-row address balance `token`. * * Page-level callers that enrich many tokens at once should pre-fetch * `createdAtByAddress` (one `IN (...)` TIDX query for the whole page) and pass * it in to avoid N separate `getTokenCreatedAtByAddress` round-trips. The same * applies to the verified-token `snapshot` and the other batch enrichments * (`createdByAddress`, `holderCountByAddress`, `transferStatsByAddress`). */ export async function resolveToken( c: Context, options: resolveToken.Options, ): Promise> { const { chainId } = options // Normalize once so address and symbol lookups share cache entries and // verified-data comparisons do not depend on caller casing. const address = Schema.Address.parse(options.address.toLowerCase()) // `createdAt`, the `TokenCreated` extras, holder counts, and transfer stats // require an expensive indexer round-trip, so each only runs when the caller // opts in via `?include=...`. Page-level callers can still pre-fetch the // batch maps for the whole page and pass them in regardless of `include`. const includeAdmin = options.include?.includes('admin') ?? false const includeCreatedAt = options.include?.includes('createdAt') ?? false const includeHolderCount = options.include?.includes('holderCount') ?? false const includeQuoteToken = options.include?.includes('quoteToken') ?? false const includeTransferStats = options.include?.includes('transferStats') ?? false const [metadata, snapshot, createdAtByAddress, createdByAddress, logo, holderCount, stats] = await Promise.all([ Timing.time(c, 'token_metadata', () => getTokenMetadata(c, { address, chainId })), options.snapshot ?? VerifiedTokens.snapshot(c, chainId), options.createdAtByAddress ?? (includeCreatedAt ? Timing.time(c, 'token_created', () => getTokenCreatedAtByAddress(c, { addresses: [address], chainId }), ) : Promise.resolve({} as Record)), // `admin` and `quoteToken` ride one `TokenCreated` fetch: either include // triggers it, and each field still surfaces independently below. options.createdByAddress ?? (includeAdmin || includeQuoteToken ? Timing.time(c, 'token_created_extras', () => getTokenCreatedByAddress(c, { addresses: [address], chainId }), ) : Promise.resolve({} as Record)), Timing.time(c, 'token_logo', () => getTokenLogo(c, { address, chainId })), options.holderCountByAddress ? Promise.resolve(options.holderCountByAddress[address] ?? null) : includeHolderCount ? Timing.time(c, 'token_holders', () => getHolderCount(c, { address, chainId })) : Promise.resolve(undefined), options.transferStatsByAddress ? Promise.resolve(options.transferStatsByAddress[address] ?? null) : includeTransferStats ? Timing.time(c, 'token_transfers', () => getTransferStats(c, { address, chainId })) : Promise.resolve(undefined), ]) const created = createdByAddress[address] return Response.validated(schema.getToken.Response, { address, // Only the explicitly requested `TokenCreated` extra surfaces: a caller // asking for `quoteToken` alone must not receive `admin`, and vice versa. admin: includeAdmin ? created?.admin : undefined, currency: metadata.currency, createdAt: createdAtByAddress[address], decimals: metadata.decimals, holderCount: holderCount ?? undefined, id: address, // Prefer the curated R2 icon, then the verified entry's curated `logoUri`, // falling back to the precompile's on-chain `logoURI` when present. logoUri: logo ?? snapshot.byAddress.get(address)?.logoUri ?? metadata.logoUri, name: metadata.name, quoteToken: includeQuoteToken ? created?.quoteToken : undefined, symbol: metadata.symbol, totalSupply: metadata.totalSupply, transferStats: stats ?? undefined, verified: snapshot.byAddress.has(address), }) } export declare namespace resolveToken { type Options = getToken.Options } /** Resolves unique token addresses with RPC metadata and requested curated fields. */ export async function resolveTokens( c: Context, options: resolveTokens.Options, ): Promise { const { chainId } = options const addresses = Array.from( new Set(options.addresses.map((address) => Schema.Address.parse(address.toLowerCase()))), ) if (addresses.length === 0) return new Map() const includeLogoUri = options.include.includes('token.logoUri') const includeVerified = options.include.includes('token.verified') const resolveMetadata = () => Promise.all( addresses.map(async (address) => { const metadata = options.metadata?.get(address) if (metadata) return [address, metadata] as const try { return [address, await getTokenMetadata(c, { address, chainId })] as const } catch { return [address, undefined] as const } }), ) const [snapshot, metadataByAddress, logoByAddress] = await Promise.all([ options.snapshot ?? (includeLogoUri || includeVerified ? VerifiedTokens.snapshot(c, chainId).catch(() => undefined) : undefined), addresses.every((address) => options.metadata?.has(address)) ? resolveMetadata() : Timing.time(c, 'token_metadata', resolveMetadata), includeLogoUri ? Timing.time(c, 'token_logo', () => Promise.all( addresses.map( async (address) => [ address, await getTokenLogo(c, { address, chainId }).catch(() => undefined), ] as const, ), ), ) : [], ]) const logos = new Map(logoByAddress) const metadataMap = new Map(metadataByAddress) const onchainLogos = new Map( includeLogoUri && options.metadata !== undefined ? await Timing.time(c, 'token_logo_uri', () => Promise.all( addresses .filter( (address) => options.metadata?.has(address) === true && !logos.get(address) && !snapshot?.byAddress.get(address)?.logoUri && !metadataMap.get(address)?.logoUri, ) .map( async (address) => [ address, await getTokenLogoUri(c, { address, chainId }).catch(() => undefined), ] as const, ), ), ) : [], ) const tokens: resolveTokens.ReturnType = new Map() for (const [address, metadata] of metadataByAddress) { if (!metadata) continue tokens.set(address, { address, currency: metadata.currency, decimals: metadata.decimals, id: address, logoUri: includeLogoUri ? (logos.get(address) ?? snapshot?.byAddress.get(address)?.logoUri ?? metadata.logoUri ?? onchainLogos.get(address)) : undefined, name: metadata.name, symbol: metadata.symbol, verified: includeVerified ? snapshot?.byAddress.has(address) : undefined, }) } return tokens } export declare namespace resolveTokens { /** Options for resolving page token metadata. */ export type Options = { /** Token addresses to resolve. */ addresses: readonly z.output[] /** Tempo chain id. */ chainId: z.output /** Curated token fields to include. */ include: readonly ('token.logoUri' | 'token.verified')[] /** Token metadata already resolved by the enclosing resource. */ metadata?: ReadonlyMap, Metadata> | undefined /** Preloaded verified-token state. */ snapshot?: VerifiedTokens.Snapshot | undefined } /** Token metadata required by compact references. */ export type Metadata = Pick< z.output, 'currency' | 'decimals' | 'logoUri' | 'name' | 'symbol' > /** Token metadata keyed by lowercase address. */ export type ReturnType = Map, Token> /** Token metadata with opt-in curated fields. */ export type Token = Omit, 'verified'> & { /** Whether the token is curated, when requested. */ verified?: boolean | undefined } } function getTokenLogoUri(c: Context, options: getTokenMetadata.Options) { const { address, chainId } = options return Store.memoize( async () => (await c.get('getClient')(chainId).readContract({ abi: Abis.tip20, address, functionName: 'logoURI', })) || null, { key: `token:v1:${chainId}:${address}:logo-uri`, store: c.get('store'), ttl: Ttl.minutes(1), }, ).then((logoUri) => logoUri ?? undefined) } /** * Resolves a token's on-chain metadata (`name`/`symbol`/`decimals`/`currency`/ * `logoUri`/`totalSupply`) via a single `token.getMetadata` RPC, memoized under the shared * `token:v1:{chainId}:{address}:metadata` key. Exported so feed enrichers (e.g. * `activities`) reuse the same cache entry as the token resource rather than * fetching metadata through a parallel path. */ export function getTokenMetadata(c: Context, options: getTokenMetadata.Options) { const { address, chainId } = options const getClient = c.get('getClient') const store = c.get('store') return Store.memoize( async () => { const fresh = await getClient(chainId).token.getMetadata({ token: address as Address }) return { currency: fresh.currency, decimals: fresh.decimals, logoUri: fresh.logoURI || undefined, name: fresh.name, symbol: fresh.symbol, totalSupply: fresh.totalSupply.toString(), } }, { key: `token:v1:${chainId}:${address}:metadata`, store, ttl: Ttl.minutes(1) }, ) } declare namespace getTokenMetadata { type Options = { address: z.output chainId: z.output } } /** * Resolves a token's curated R2 icon URI, memoized per `(chainId, address)` * so page enrichers (balances, holders, transfers `include=token.logoUri`) don't * re-fetch the asset per row on every request. Misses are memoized as `null` * too — most tokens have no curated icon, and the negative lookup is exactly * what page enrichment would otherwise repeat N times per page. * * The public URI embeds the request origin, so the origin is part of the * cache key; cardinality stays bounded by the number of public hostnames. */ export async function getTokenLogo(c: Context, options: getTokenLogo.Options) { const { address, chainId } = options const getAsset = c.get('getAsset') const store = c.get('store') const origin = new URL(c.req.url).origin const uri = await Store.memoize( async () => (await getAsset(chainId, Path.join('icons', address)))?.uri ?? null, { key: `token:v1:${chainId}:${address}:logo:${origin}`, store, ttl: Ttl.minutes(5) }, ) return uri ?? undefined } declare namespace getTokenLogo { type Options = { address: z.output chainId: z.output } } /** * Resolves the raw logo image bytes for a token, in priority order: * * 1. The curated R2 icon (`/icons/
`), our override surface. * 2. The token's on-chain TIP-20 `logoURI` (fetched and proxied), so tokens * that publish a logo on-chain still serve through the API even without a * curated icon. * * Returns `undefined` when neither source yields an image, so the route can * map that to a `404`. The curated R2 icon is trusted (we control it); the * on-chain `logoURI` is deployer-controlled, so it is fetched through the * hardened {@link fetchExternalLogo} (scheme/host allowlist, timeout, size cap, * raster-only content type) to bound the SSRF / resource-exhaustion surface. */ async function fetchTokenLogo( c: Context, options: fetchTokenLogo.Options, ): Promise { const { address, chainId } = options // Curated R2 icon first: our override for tokens with no (or an undesirable) // on-chain logo. Trusted content, served verbatim. const getAsset = c.get('getAsset') const asset = await getAsset(chainId, Path.join('icons', address)) if (asset) return { body: await asset.response.arrayBuffer(), contentType: asset.response.headers.get('content-type') ?? 'application/octet-stream', } // Fall back to the on-chain TIP-20 `logoURI` (memoized with the rest of the // token metadata). Deployer-controlled, so proxy it defensively. const { logoUri } = await getTokenMetadata(c, { address, chainId }) if (!logoUri) return undefined return fetchExternalLogo(logoUri) } declare namespace fetchTokenLogo { type Options = { address: z.output chainId: z.output } /** Raw logo image bytes plus the MIME type to serve them with. */ type Logo = { body: ArrayBuffer contentType: string } } // Cap on proxied logo bytes (1 MiB). A logo larger than this is almost // certainly not a real icon; the cap bounds memory for a hostile upstream. const maxLogoBytes = 1_000_000 /** * Fetches a deployer-controlled `logoURI` and returns the image bytes, or * `undefined` when the URI is unsafe/unusable. Hardening, since the URI is * attacker-controlled and proxied through our origin: * * - Scheme allowlist (`http`/`https`/`data`); `ipfs://` etc. are unresolvable. * - Blocks loopback/private/link-local hosts to limit SSRF (defense in depth — * Workers `fetch` is not routable to internal infra, but cheap to enforce). * - 3s timeout and a {@link maxLogoBytes} streamed size cap bound resource use. * - Raster-only content type: arbitrary on-chain `image/svg+xml` is rejected * (script-bearing SVG served same-origin is an XSS vector); curated R2 SVGs * are trusted and handled separately. */ async function fetchExternalLogo(uri: string): Promise { const url = (() => { try { return new URL(uri) } catch { return undefined } })() if (!url) return undefined if (url.protocol !== 'http:' && url.protocol !== 'https:' && url.protocol !== 'data:') return undefined if ( (url.protocol === 'http:' || url.protocol === 'https:') && ExternalLogo.isBlockedHost(url.hostname) ) return undefined const response = await fetch(url, { headers: { accept: 'image/png,image/jpeg,image/webp,image/gif,image/avif,image/*;q=0.8' }, redirect: 'manual', signal: AbortSignal.timeout(3_000), }).catch(() => undefined) // A non-2xx (incl. an opaque 3xx from `redirect: 'manual'`, so a deployer // cannot bounce us to an internal host) is treated as no logo. if (!response || !response.ok) return undefined const contentType = response.headers.get('content-type')?.split(';')[0]?.trim().toLowerCase() if (!contentType || !contentType.startsWith('image/') || contentType === 'image/svg+xml') return undefined // Reject early when the upstream advertises an oversized body. const length = Number(response.headers.get('content-length')) if (Number.isFinite(length) && length > maxLogoBytes) return undefined const body = await readBodyWithLimit(response.body, maxLogoBytes) if (!body) return undefined return { body, contentType } } // Reads a stream into an `ArrayBuffer`, returning `undefined` if it exceeds // `limit` (so an upstream without `Content-Length` cannot stream us unbounded // bytes). async function readBodyWithLimit( body: ReadableStream | null, limit: number, ): Promise { if (!body) return undefined const reader = body.getReader() const chunks: Uint8Array[] = [] let total = 0 try { for (;;) { const { done, value } = await reader.read() if (done) break total += value.byteLength if (total > limit) return undefined chunks.push(value) } } catch { return undefined } finally { reader.releaseLock() } const buffer = new ArrayBuffer(total) const out = new Uint8Array(buffer) let offset = 0 for (const chunk of chunks) { out.set(chunk, offset) offset += chunk.byteLength } return buffer } namespace ExternalLogo { // Blocks hosts that resolve to loopback/private/link-local space, including the // cloud metadata IP. Hostnames that are not literal IPs are allowed (we cannot // resolve DNS here); `redirect: 'manual'` prevents a public host from bouncing // into private space. export function isBlockedHost(hostname: string): boolean { const host = hostname.toLowerCase().replace(/^\[|\]$/g, '') if (host === '' || host === 'localhost' || host.endsWith('.localhost')) return true if (host === '::1' || host === '::') return true if (host.startsWith('fc') || host.startsWith('fd') || host.startsWith('fe80:')) return true const ipv4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host) if (ipv4) { const a = Number(ipv4[1]) const b = Number(ipv4[2]) if (a === 0 || a === 10 || a === 127) return true if (a === 169 && b === 254) return true if (a === 172 && b >= 16 && b <= 31) return true if (a === 192 && b === 168) return true if (a === 100 && b >= 64 && b <= 127) return true } return false } } async function getTokenLogosByAddress( c: Context, options: getTokenLogosByAddress.Options, ): Promise> { const { addresses, chainId } = options // Curated R2 icons take precedence (our override for tokens that have no // on-chain logo); resolve them all in parallel. const assets = await Promise.all( addresses.map( async (address) => [address, await getTokenLogo(c, { address, chainId })] as const, ), ) const logos: Record = {} let missing: z.output[] = [] for (const [address, uri] of assets) if (uri) logos[address] = uri else missing.push(address) if (missing.length === 0) return logos // Next, the verified entry's curated `logoUri` (set via the admin API). The // snapshot is primed per isolate, so this is an in-memory map read. const snapshot = await VerifiedTokens.snapshot(c, chainId) missing = missing.filter((address) => { const uri = snapshot.byAddress.get(address)?.logoUri if (uri) logos[address] = uri return !uri }) if (missing.length === 0) return logos // Fall back to the on-chain `logoURI` for tokens without a curated icon. // Reuse `getTokenMetadata` so the lookup shares the same memoized cache as the // single-token endpoint; the per-token reads batch into a deployless // multicall. Failures degrade to no logo rather than failing the page. const onchain = await Promise.all( missing.map(async (address) => { try { return [address, (await getTokenMetadata(c, { address, chainId })).logoUri] as const } catch { return [address, undefined] as const } }), ) for (const [address, uri] of onchain) if (uri) logos[address] = uri return logos } declare namespace getTokenLogosByAddress { type Options = { addresses: readonly z.output[] chainId: z.output } } function getHolderCount(c: Context, options: getHolderCount.Options) { const { address, chainId } = options const store = c.get('store') const tidx = c.get('getTidx')(chainId) return Store.memoize( async () => { try { // Holder count for the token. Read the pre-aggregated // `token_holder_counts` materialized view (one row per `token`, refreshed // every ~15 min from the positive-balance `token_balances_snapshot`), so // this is a single point lookup on the view's `(token)` sort key rather // than an aggregation over the holder set. `address` is already lowercase // (Schema.Address), matching how the indexer stores the token column. The // inline query is cast to `string` so TIDX treats it as a dynamic // ClickHouse query that needs no event signature for the // `token_holder_counts` table. const result = await tidx.fetch({ chainId, engine: 'clickhouse', query: ` SELECT holder_count FROM token_holder_counts WHERE token = '${address}' ` as string, }) // Persist `null` (not `undefined`) so "no holder data" survives the // cache round-trip without being mistaken for a miss. return Value.toNumber(result.rows[0]?.['holder_count']) ?? null } catch { // Degrade gracefully when holder data is unavailable (e.g. the indexer // lacks the ClickHouse view, or the request is rate limited) so token // metadata still resolves. return null } }, { key: `token:v1:${chainId}:${address}:holders`, store, ttl: Ttl.minutes(5) }, ) } declare namespace getHolderCount { type Options = { address: z.output chainId: z.output } } async function getHolderCountsByAddress( c: Context, options: getHolderCountsByAddress.Options, ): Promise> { const addresses = Array.from(new Set(options.addresses.map((address) => address.toLowerCase()))) .sort() .map((address) => Schema.Address.parse(address)) const store = c.get('store') const tidx = c.get('getTidx')(options.chainId) if (addresses.length === 0) return {} return Store.memoize( async () => { try { // Batched form of `getHolderCount` for a page of tokens: one multi-key // point read of the pre-aggregated `token_holder_counts` materialized // view instead of a `GROUP BY` aggregation over every requested token's // holder set in `token_balances_snapshot` (which, for high-cardinality // tokens, reads millions of rows per group). const result = await tidx.fetch({ chainId: options.chainId, engine: 'clickhouse', query: ` SELECT token, holder_count FROM token_holder_counts WHERE token IN (${addresses.map((address) => `'${address}'`).join(', ')}) ` as string, }) const holderCountByAddress: Record = {} for (const row of result.rows) { const address = Schema.Address.safeParse(row['token']) const holderCount = Value.toNumber(row['holder_count']) if (address.success && holderCount !== undefined) holderCountByAddress[address.data] = holderCount } return holderCountByAddress } catch { // Degrade gracefully so the token page still resolves without counts. return {} } }, { key: `token:v1:${options.chainId}:holders:${addresses.join(',')}`, store, ttl: Ttl.minutes(5), }, ) } declare namespace getHolderCountsByAddress { type Options = { addresses: readonly z.output[] chainId: z.output } } function getTransferStats(c: Context, options: getTransferStats.Options) { const { address, chainId } = options const store = c.get('store') const tidx = c.get('getTidx')(chainId) return Store.memoize( async () => { // Lifetime `Transfer` statistics as three primary-key probes instead of // one full aggregation: `count()` is index-counted and first/last read // in `block_num` order, keeping high-volume tokens under the indexer's // execution cap. The inline query is cast to `string` so TIDX treats it // as a dynamic ClickHouse query that needs no event signature for the // `logs` table. const result = await tidx.fetch({ chainId, engine: 'clickhouse', query: ` SELECT (SELECT count() FROM logs WHERE address = '${address}' AND selector = '${transferTopic}') AS transfer_count, (SELECT block_timestamp FROM logs WHERE address = '${address}' AND selector = '${transferTopic}' ORDER BY block_num ASC LIMIT 1) AS first_at, (SELECT block_timestamp FROM logs WHERE address = '${address}' AND selector = '${transferTopic}' ORDER BY block_num DESC LIMIT 1) AS last_at ` as string, }) const row = result.rows[0] const count = Value.toNumber(row?.['transfer_count']) // A malformed row is an upstream failure; degrade like any other error // rather than caching a bogus value. if (count === undefined) throw new Error('malformed transfer stats row') // Empty first/last probes can render as epoch defaults depending on // engine settings; pin the documented shape for zero-transfer tokens. if (count === 0) return { count: 0, firstAt: null, lastAt: null } // ClickHouse formats timestamps as naive-UTC `YYYY-MM-DD HH:MM:SS.sss`; // `Value.toIsoDateTime` normalizes them to strict ISO 8601 UTC. return { count, firstAt: Value.toIsoDateTime(row?.['first_at']) ?? null, lastAt: Value.toIsoDateTime(row?.['last_at']) ?? null, } }, { key: `token:v1:${chainId}:${address}:transfer_stats`, store, ttl: Ttl.minutes(5) }, ).catch(() => { // Degrade gracefully so token metadata still resolves. Failures are not // memoized and the degraded response stays out of the response cache, so // the next request retries instead of seeing a blank field for the TTL. Cache.setPolicy(c, Cache.policies.noStore) return null }) } declare namespace getTransferStats { /** Lifetime `Transfer` statistics for one token, as surfaced on the token resource. */ type Stats = Exclude['transferStats'], undefined> type Options = { address: z.output chainId: z.output } } async function getTransferStatsByAddress( c: Context, options: getTransferStatsByAddress.Options, ): Promise> { const addresses = Array.from(new Set(options.addresses.map((address) => address.toLowerCase()))) .sort() .map((address) => Schema.Address.parse(address)) const store = c.get('store') const tidx = c.get('getTidx')(options.chainId) if (addresses.length === 0) return {} return Store.memoize>( async () => { // Batched form of `getTransferStats`: the same three primary-key probes // per token, stitched with UNION ALL in chunks of three (the TIDX proxy // rejects deeper subquery nesting). Zero-transfer tokens surface // `(0, null, null)` rather than dropping out. const chunks: (typeof addresses)[] = [] for (let i = 0; i < addresses.length; i += 3) chunks.push(addresses.slice(i, i + 3)) const statsByAddress: Record = {} // Waves of four chunks in flight: the indexer rejects wide bursts of // concurrent queries, and a 200-token page would otherwise fan out ~67 // requests at once. for (let i = 0; i < chunks.length; i += 4) { const results = await Promise.all( chunks.slice(i, i + 4).map((group) => tidx.fetch({ chainId: options.chainId, engine: 'clickhouse', query: group .map( (address) => ` SELECT '${address}' AS address, (SELECT count() FROM logs WHERE address = '${address}' AND selector = '${transferTopic}') AS transfer_count, (SELECT block_timestamp FROM logs WHERE address = '${address}' AND selector = '${transferTopic}' ORDER BY block_num ASC LIMIT 1) AS first_at, (SELECT block_timestamp FROM logs WHERE address = '${address}' AND selector = '${transferTopic}' ORDER BY block_num DESC LIMIT 1) AS last_at`, ) .join('\n UNION ALL'), }), ), ) for (const row of results.flatMap((result) => result.rows)) { const address = Schema.Address.safeParse(row['address']) const count = Value.toNumber(row['transfer_count']) if (!address.success || count === undefined) continue // Empty first/last probes can render as epoch defaults depending on // engine settings; pin the documented shape for zero-transfer tokens. statsByAddress[address.data] = count === 0 ? { count: 0, firstAt: null, lastAt: null } : { count, firstAt: Value.toIsoDateTime(row['first_at']) ?? null, lastAt: Value.toIsoDateTime(row['last_at']) ?? null, } } } return statsByAddress }, { key: `token:v1:${options.chainId}:transfer_stats:${addresses.join(',')}`, store, ttl: Ttl.minutes(5), }, ).catch(() => { // Degrade gracefully so the token page still resolves without stats. // Failures are not memoized and the degraded response stays out of the // response cache, so the next request retries. Cache.setPolicy(c, Cache.policies.noStore) return {} }) } declare namespace getTransferStatsByAddress { type Options = { addresses: readonly z.output[] chainId: z.output } } function getTokenHolders(c: Context, options: getTokenHolders.Options) { const { address, chainId, limit } = options const store = c.get('store') const tidx = c.get('getTidx')(chainId) // Keyset pagination on `(balance, holder)`: balance is descending and `holder` // is a stable tiebreaker for the many holders sharing a balance. A malformed // cursor falls back to the head page. Note: because `balance` is mutable, this // is best-effort across balance changes (the inherent limit of ranking feeds). const cursor = options.cursor ? Cursor.decode(options.cursor, ['uint', 'address']) : undefined // Bounded positional lane (exclusive with `cursor` at the schema): page 1 is // the head page, so it shares the head's SQL and cache entries; deeper pages // translate to a SQL OFFSET of `(page - 1) * limit` rows. const offset = options.page !== undefined && options.page > 1 ? (options.page - 1) * limit : undefined return Timing.time(c, 'token_holders', () => Store.memoize( async () => { // A holder is an address with a positive balance of the token. Read the // pre-aggregated, `FINAL`-correct `token_balances_snapshot` materialized // view (one row per `(token, holder, balance)`, ordered by // `(token, balance)`), so this is a primary-key read that stays under the // indexer's ClickHouse budget even for high-cardinality tokens (e.g. // PathUSD). Fetch one extra row to detect `hasMore` without a separate // count query. The inline query is cast to `string` so TIDX treats it as // a dynamic ClickHouse query that needs no event signature for the // `token_balances_snapshot` table. // // Let upstream failures propagate: unlike `getHolderCount` (optional // enrichment that degrades to `null`), this is a dedicated endpoint, so a // failure should surface as the declared `502` rather than masquerade as // a token with zero holders. const keyset = cursor !== undefined ? ` AND ${Cursor.keyset([ { literal: Cursor.literal(cursor[0]!, 'uint'), name: 'balance', order: 'desc' }, { literal: Cursor.literal(cursor[1]!, 'address'), name: 'holder', order: 'asc' }, ])}` : '' // `balance` is UInt256; SELECT it as a string so values > 2^53 survive // JSON without precision loss. The keyset cursor encodes the row's // exact balance, so any rounding would re-include the cursor row on // the next page. Alias is `balance_str` (not `balance`) because TIDX // rejects expression aliases that shadow the source column (422). const result = await tidx.fetch({ chainId, engine: 'clickhouse', query: ` SELECT holder, toString(balance) AS balance_str FROM token_balances_snapshot WHERE token = '${address}' AND balance > 0${keyset} ORDER BY balance DESC, holder ASC LIMIT ${limit + 1}${offset !== undefined ? ` OFFSET ${offset}` : ''} ` as string, }) // The next page anchors below the last fetched row's `(balance, holder)`. const page = Cursor.paginate({ rows: result.rows, limit, key: (row) => { const balance = Value.toIntegerString(row['balance_str']) const holder = Schema.Address.safeParse(row['holder']) return balance !== undefined && holder.success ? [balance, holder.data] : undefined }, }) const data: { address: string; balance: string; id: string }[] = [] for (const row of page.rows) { const holder = Schema.Address.safeParse(row['holder']) const balance = Value.toIntegerString(row['balance_str']) if (holder.success && balance !== undefined) data.push({ address: holder.data, balance, id: holder.data }) } return { data, nextCursor: page.nextCursor } }, { key: `token:v1:${chainId}:${address}:holders:${cursor ? `cursor:${cursor[0]}:${cursor[1]}` : offset !== undefined ? `page:${options.page}` : 'head'}:${limit}`, store, ttl: Ttl.minutes(1), }, ), ) } declare namespace getTokenHolders { type Options = { address: z.output chainId: z.output /** Opaque keyset cursor anchoring the page; omit for the head page. */ cursor?: string | undefined limit: number /** 1-indexed page number (positional pagination; exclusive with `cursor`). */ page?: number | undefined } } /** * Resolves `createdAt` for many tokens in one TIDX round-trip while keeping * cache entries keyed per-address, so two pages sharing N-1 of N tokens * naturally reuse most of the cache. We: * * 1. Look up each requested address in the per-address cache. * 2. Issue a single `IN (…)` query for the misses only. * 3. Write each freshly-resolved address back to the per-address cache. * * Token creation timestamps are immutable, so successful entries are cached * for a day. Addresses the indexer answered but did not resolve (no * `TokenCreated` row — e.g. genesis tokens) are negative-cached briefly so * they do not re-run the `logs` decode CTE on every request; the short TTL * covers tokens that are simply not indexed yet. */ export async function getTokenCreatedAtByAddress( c: Context, options: getTokenCreatedAtByAddress.Options, ): Promise> { const addresses = Array.from(new Set(options.addresses.map((address) => address.toLowerCase()))) .sort() .map((address) => Schema.Address.parse(address)) const store = c.get('store') const tidx = c.get('getTidx')(options.chainId) if (addresses.length === 0) return {} const key = (address: string) => `token:v1:${options.chainId}:${address}:created_at` const cached = await Promise.all(addresses.map((address) => store.get(key(address)))) const createdAtByAddress: Record = {} const misses: string[] = [] for (const [index, address] of addresses.entries()) { const hit = cached[index] // A negative entry means "known to have no TokenCreated row" — resolved // as absent, so it neither joins the result nor re-queries. if (hit === noCreatedAt) continue if (hit) createdAtByAddress[address] = hit else misses.push(address) } if (misses.length === 0) return createdAtByAddress try { const result = await tidx.fetch({ chainId: options.chainId, // TODO: Remove the explicit engine after TIDX 1.0 is released. engine: 'clickhouse', query: ` SELECT token, block_timestamp FROM tokencreated WHERE token IN (${misses.map((address) => `'${address}'`).join(', ')}) `, signatures: [tokenCreatedSignature], }) const writes: Promise[] = [] for (const row of result.rows) { const address = Schema.Address.safeParse(row.token) const createdAt = Value.toIsoDateTime(row.block_timestamp) if (!address.success || !createdAt) continue createdAtByAddress[address.data] = createdAt writes.push(store.put(key(address.data), createdAt, { ttl: Ttl.days(1) })) } // Negative-cache the addresses this (successful) query did not resolve, so // a token with no `TokenCreated` row doesn't re-run the decode CTE on // every request. for (const address of misses) if (!(address in createdAtByAddress)) writes.push(store.put(key(address), noCreatedAt, { ttl: Ttl.minutes(1) })) // Persist new entries best-effort; do not block the response on cache writes. void Promise.all(writes).catch(() => {}) } catch { // Creation timestamps improve resource completeness, but token metadata // should remain available when the indexer is temporarily unable to // answer this auxiliary query. } return createdAtByAddress } /** * Negative-cache sentinel for {@link getTokenCreatedAtByAddress}: stored under * an address's `created_at` key when the indexer has no `TokenCreated` row * for it, distinguishable from any real ISO timestamp. */ const noCreatedAt = 'none' export declare namespace getTokenCreatedAtByAddress { type Options = { addresses: readonly z.output[] chainId: z.output } } /** * Resolves the `TokenCreated` extras (`admin`/`quoteToken`, plus the event's * `createdAt` timestamp) for many tokens in one TIDX round-trip while keeping * cache entries keyed per-address, mirroring {@link getTokenCreatedAtByAddress}. * The deployed indexer's `tokencreated` event CTE cannot serve the * `quoteToken`/`admin` columns (selecting them returns `db error`), so this * reads the raw `logs` table by topic0 and ABI-decodes the non-indexed event * data instead. * * Token creation payloads are immutable, so successful entries are cached for * a day. Addresses the indexer answered but did not resolve (no `TokenCreated` * log — e.g. genesis tokens) are negative-cached briefly so they do not * re-scan `logs` on every request; the short TTL covers tokens that are simply * not indexed yet. */ async function getTokenCreatedByAddress( c: Context, options: getTokenCreatedByAddress.Options, ): Promise> { const addresses = Array.from(new Set(options.addresses.map((address) => address.toLowerCase()))) .sort() .map((address) => Schema.Address.parse(address)) const store = c.get('store') const tidx = c.get('getTidx')(options.chainId) if (addresses.length === 0) return {} const key = (address: string) => `token:v2:${options.chainId}:${address}:created` const cached = await Promise.all(addresses.map((address) => store.get(key(address)))) const createdByAddress: Record = {} const misses: string[] = [] for (const [index, address] of addresses.entries()) { const hit = cached[index] // A negative entry means "known to have no TokenCreated log" — resolved // as absent, so it neither joins the result nor re-queries. if (hit === noCreated) continue if (hit) createdByAddress[address] = JSON.parse(hit) as getTokenCreatedByAddress.Created else misses.push(address) } if (misses.length === 0) return createdByAddress try { // The raw `logs` table stores topic0 in the `selector` column; `topic1` is // the indexed `token` address left-padded to a 32-byte topic. The inline // query is cast to `string` so TIDX treats it as a dynamic query that // needs no event signature for the `logs` table. const result = await tidx.fetch({ chainId: options.chainId, // TODO: Remove the explicit engine after TIDX 1.0 is released. engine: 'clickhouse', query: ` SELECT topic1, data, block_timestamp FROM logs WHERE selector = '${tokenCreatedTopic}' AND topic1 IN (${misses.map((address) => `'0x${'0'.repeat(24)}${address.slice(2)}'`).join(', ')}) ` as string, }) const writes: Promise[] = [] for (const row of result.rows) { const topic = Value.toText(row['topic1']) const data = Schema.Hex.safeParse(row['data']) const createdAt = Value.toIsoDateTime(row['block_timestamp']) if (!topic || !data.success || !createdAt) continue // `topic1` left-pads the address to 32 bytes; strip the padding. const address = Schema.Address.safeParse(`0x${topic.slice(-40)}`) if (!address.success) continue const decoded = (() => { try { return AbiParameters.decode(tokenCreatedData, data.data) } catch { return undefined } })() if (!decoded) continue const quoteToken = Schema.Address.safeParse(decoded[3]) const admin = Schema.Address.safeParse(decoded[4]) if (!quoteToken.success || !admin.success) continue const created = { admin: admin.data, createdAt, quoteToken: quoteToken.data } createdByAddress[address.data] = created writes.push(store.put(key(address.data), JSON.stringify(created), { ttl: Ttl.days(1) })) } // Negative-cache the addresses this (successful) query did not resolve, so // a token with no `TokenCreated` log doesn't re-scan `logs` on every // request. for (const address of misses) if (!(address in createdByAddress)) writes.push(store.put(key(address), noCreated, { ttl: Ttl.minutes(1) })) // Persist new entries best-effort; do not block the response on cache writes. void Promise.all(writes).catch(() => {}) } catch { // `TokenCreated` extras improve resource completeness, but token metadata // should remain available when the indexer is temporarily unable to // answer this auxiliary query. } return createdByAddress } /** * Negative-cache sentinel for {@link getTokenCreatedByAddress}: stored under * an address's `created` key when the indexer has no `TokenCreated` log for * it, distinguishable from any real JSON payload. */ const noCreated = 'none' declare namespace getTokenCreatedByAddress { /** `TokenCreated` payload fields resolved per token. */ type Created = { /** Token admin address from the `TokenCreated` event. */ admin: z.output /** Token creation timestamp (ISO 8601). */ createdAt: string /** Quote token address from the `TokenCreated` event. */ quoteToken: z.output } type Options = { addresses: readonly z.output[] chainId: z.output } } async function getTokens(c: Context, options: getTokens.Options) { const store = c.get('store') const tidx = c.get('getTidx')(options.chainId) const snapshot = await VerifiedTokens.snapshot(c, options.chainId) // `verified=true` is served from the curated static list — TIDX's event-CTE // planner rejects `WHERE token IN (…)` against `tokencreated` when combined // with `ORDER BY` / `LIMIT`, so we paginate the in-memory list positionally // instead (see the branch below). The response keeps the same shape as the // unfiltered page: `currency` narrows the static rows, the `include` // enrichments run against the page's addresses, and `cursor`/`page`/`limit`/ // `order` slice the list. // `createdAt` is an opt-in `include`: skip the indexer round-trip unless the // caller asked for it, keeping the base page fast. const wantCreatedAt = options.include?.includes('createdAt') ?? false // `admin` and `quoteToken` both decode from the raw `TokenCreated` log, so // one batched fetch serves either; each field still only surfaces when its // own include was requested. Holder counts and transfer stats are likewise // opt-in batched enrichments. const wantAdmin = options.include?.includes('admin') ?? false const wantQuoteToken = options.include?.includes('quoteToken') ?? false const wantCreatedExtras = wantAdmin || wantQuoteToken const wantHolderCount = options.include?.includes('holderCount') ?? false const wantTransferStats = options.include?.includes('transferStats') ?? false // An explicit `addresses=` batch lookup takes precedence over both the // verified-list branch and the indexed listing: the caller already named the // exact tokens, so `cursor`/`order` are inapplicable and the page is the // resolved set in input order. Batch enrichments are pre-fetched here (one // TIDX query per include) and passed through `resolveToken`, mirroring the // verified branch, so the per-address fan-out costs RPC metadata only. if (options.addresses !== undefined) { const addresses = options.addresses const [createdAtByAddress, createdByAddress, holderCountByAddress, transferStatsByAddress] = await Promise.all([ wantCreatedAt && addresses.length > 0 ? Timing.time(c, 'tokens_created_at', () => getTokenCreatedAtByAddress(c, { addresses, chainId: options.chainId }), ) : Promise.resolve({} as Record), wantCreatedExtras && addresses.length > 0 ? Timing.time(c, 'tokens_created_extras', () => getTokenCreatedByAddress(c, { addresses, chainId: options.chainId }), ) : Promise.resolve({} as Record), wantHolderCount && addresses.length > 0 ? Timing.time(c, 'tokens_holders', () => getHolderCountsByAddress(c, { addresses, chainId: options.chainId }), ) : Promise.resolve(undefined), wantTransferStats && addresses.length > 0 ? Timing.time(c, 'tokens_transfers', () => getTransferStatsByAddress(c, { addresses, chainId: options.chainId }), ) : Promise.resolve(undefined), ]) // Unresolvable addresses (not a TIP-20 token, upstream failure) drop out // of the page rather than failing it; input order is preserved. const resolved = await Promise.all( addresses.map((address) => resolveToken(c, { address, chainId: options.chainId, createdAtByAddress, createdByAddress, holderCountByAddress, include: options.include, snapshot, transferStatsByAddress, }).catch(() => undefined), ), ) // `currency` and `verified=true` act as post-filters on the resolved set: // the caller addressed specific tokens, so non-matching rows drop out // instead of erroring. const currency = options.currency?.toLowerCase() return { data: resolved.filter( (token): token is Exclude => token !== undefined && (currency === undefined || token.currency.toLowerCase() === currency) && (options.verified !== true || token.verified), ), nextCursor: null, } } if (options.verified) { const all = options.currency ? (snapshot.byCurrency.get(options.currency.toLowerCase()) ?? []) : snapshot.list // The curated list is static and fully in memory, so paginate it // positionally: the cursor encodes the next offset, `page` slices by index, // and `order=asc` reverses the canonical (`desc`) order. Only the page slice // is enriched below, so a request costs at most `limit` logo/stat lookups // instead of one per verified token. const ordered = options.order === 'asc' ? [...all].reverse() : all const start = (Cursor.decode(options.cursor ?? '', ['int'])?.[0] as number | undefined) ?? (options.page !== undefined && options.page > 1 ? (options.page - 1) * options.limit : 0) const rows = ordered.slice(start, start + options.limit) const nextCursor = ordered.length > start + options.limit ? Cursor.encode([start + options.limit]) : null // `createdAt`, the `TokenCreated` extras, holder counts, and transfer // stats come from TIDX. We fetch them in parallel so the verified page // costs at most one round-trip per enrichment — and each is only fetched // when opted into via `include`. const addresses = rows.map((token) => token.address) const [createdAtByAddress, createdByAddress, holderCounts, logoByAddress, statsByAddress] = await Promise.all([ wantCreatedAt && rows.length > 0 ? Timing.time(c, 'tokens_created_at', () => getTokenCreatedAtByAddress(c, { addresses, chainId: options.chainId }), ) : Promise.resolve({} as Record), wantCreatedExtras && rows.length > 0 ? Timing.time(c, 'tokens_created_extras', () => getTokenCreatedByAddress(c, { addresses, chainId: options.chainId }), ) : Promise.resolve({} as Record), wantHolderCount && rows.length > 0 ? Timing.time(c, 'tokens_holders', () => getHolderCountsByAddress(c, { addresses, chainId: options.chainId }), ) : Promise.resolve(undefined), // Logos always ride along on the verified page: the curated set is small // and the page is cached. Curated R2 icons take precedence, falling back // to the on-chain `logoURI` for any token without one. Timing.time(c, 'tokens_logos', () => getTokenLogosByAddress(c, { addresses, chainId: options.chainId }), ), wantTransferStats && rows.length > 0 ? Timing.time(c, 'tokens_transfers', () => getTransferStatsByAddress(c, { addresses, chainId: options.chainId }), ) : Promise.resolve(undefined), ]) return { data: rows.map((token) => { const created = createdByAddress[token.address] return { address: token.address, admin: wantAdmin ? created?.admin : undefined, currency: token.currency, createdAt: createdAtByAddress[token.address], decimals: token.decimals, holderCount: holderCounts?.[token.address], id: token.address, logoUri: logoByAddress[token.address], name: token.name, quoteToken: wantQuoteToken ? created?.quoteToken : undefined, symbol: token.symbol, transferStats: statsByAddress?.[token.address], verified: true, } }), nextCursor, } } const limit = options.limit const order = options.order const direction = order === 'asc' ? 'ASC' : 'DESC' // Keyset pagination on `(block_num, log_idx)`: anchor the page below the // previous row's creation position instead of a numeric offset, so newly // created tokens can't shift items across pages. A malformed cursor falls back // to the head page. const cursor = options.cursor ? Cursor.decode(options.cursor, ['int', 'int']) : undefined // Bounded positional lane (exclusive with `cursor` at the schema): page 1 is // the head page, so it shares the head's SQL and cache entries; deeper pages // translate to a SQL OFFSET of `(page - 1) * limit` rows. const offset = options.page !== undefined && options.page > 1 ? (options.page - 1) * limit : undefined function getIndexedTokens() { return Store.memoize( async () => { // TIDX's event-CTE planner refuses `WHERE token = X` / `token IN (…)` // on `tokencreated` when combined with `ORDER BY` / `LIMIT` (column // equality on event params errors with `db error`). Supported filters // here are real columns like `block_num` and `currency`, plus the // cursor keyset; verified-only mode is handled above by serving the // curated static list directly. const filters: string[] = [] if (cursor !== undefined) filters.push( Cursor.keyset([ { literal: Cursor.literal(cursor[0]!, 'int'), name: 'block_num', order }, { literal: Cursor.literal(cursor[1]!, 'int'), name: 'log_idx', order }, ]), ) if (options.currency !== undefined) filters.push(`LOWER(currency) = LOWER('${Tidx.escape(options.currency)}')`) const where = filters.length > 0 ? `WHERE ${filters.join(' AND ')}` : '' const result = await tidx.fetch({ chainId: options.chainId, query: ` SELECT token, block_num, log_idx, block_timestamp, currency, name, symbol FROM tokencreated ${where} ORDER BY block_num ${direction}, log_idx ${direction} LIMIT ${limit + 1}${offset !== undefined ? ` OFFSET ${offset}` : ''} `, signatures: [tokenCreatedSignature], }) // The next page anchors below the last fetched row's `(block, log_idx)`. const page = Cursor.paginate({ rows: result.rows, limit, key: (row) => { const block = Value.toNumber(row.block_num) const index = Value.toNumber(row.log_idx) return block !== undefined && index !== undefined ? [block, index] : undefined }, }) return { nextCursor: page.nextCursor, rows: page.rows.map((row) => ({ currency: row.currency, createdAt: Value.toIsoDateTime(row.block_timestamp), name: row.name, symbol: row.symbol, token: row.token, })), } }, { key: `token:v1:${options.chainId}:indexed:${order}:${encodeURIComponent(options.currency?.toLowerCase() ?? '')}:${cursor ? `cursor:${cursor[0]}:${cursor[1]}` : offset !== undefined ? `page:${options.page}` : 'head'}:${limit}`, store, ttl: Ttl.minutes(2), }, ) } const chunk = await Timing.time(c, 'tokens_indexed', () => getIndexedTokens()) const addresses = chunk.rows.map((row) => row.token) // `TokenCreated` extras, holder counts, and transfer stats are opt-in // `include`s; logos always ride along (curated R2 icon, falling back to the // on-chain `logoURI`). Resolve all in parallel. const [createdByAddress, holderCounts, logoByAddress, statsByAddress] = await Promise.all([ wantCreatedExtras && chunk.rows.length > 0 ? Timing.time(c, 'tokens_created_extras', () => getTokenCreatedByAddress(c, { addresses, chainId: options.chainId }), ) : Promise.resolve({} as Record), wantHolderCount && chunk.rows.length > 0 ? Timing.time(c, 'tokens_holders', () => getHolderCountsByAddress(c, { addresses, chainId: options.chainId }), ) : Promise.resolve(undefined), chunk.rows.length > 0 ? Timing.time(c, 'tokens_logos', () => getTokenLogosByAddress(c, { addresses, chainId: options.chainId }), ) : Promise.resolve({} as Record), wantTransferStats && chunk.rows.length > 0 ? Timing.time(c, 'tokens_transfers', () => getTransferStatsByAddress(c, { addresses, chainId: options.chainId }), ) : Promise.resolve(undefined), ]) return { data: chunk.rows.map((row) => { const created = createdByAddress[row.token] return { address: row.token, admin: wantAdmin ? created?.admin : undefined, currency: row.currency, // `createdAt` rides along free in the indexed page query, but stays an // opt-in field for a consistent contract with the verified page. createdAt: wantCreatedAt ? row.createdAt : undefined, decimals: 6, holderCount: holderCounts?.[row.token], id: row.token, logoUri: logoByAddress[row.token], name: row.name, quoteToken: wantQuoteToken ? created?.quoteToken : undefined, symbol: row.symbol, transferStats: statsByAddress?.[row.token], verified: snapshot.byAddress.has(row.token), } }), nextCursor: chunk.nextCursor, } } declare namespace getTokens { type Options = { /** * Exact token addresses to fetch as a single page in input order; * takes precedence over the verified and indexed listing modes. */ addresses?: readonly z.output[] | undefined chainId: z.output /** Only include tokens denominated in this currency (e.g. `USD`). */ currency?: string | undefined /** Opaque keyset cursor anchoring the page; omit for the head page. */ cursor?: string | undefined include?: readonly z.output[] | undefined limit: number /** 1-indexed page number (positional pagination; exclusive with `cursor`). */ page?: number | undefined order: 'asc' | 'desc' /** When true, restrict the page to the curated verified token list. */ verified?: boolean | undefined } } /** Matches metadata-read failures for addresses that are not TIP-20 tokens. */ export function isTokenNotFound(cause: unknown) { if (cause instanceof ContractFunctionExecutionError) return true if (cause instanceof Error && cause.message.includes('invalid tip20 address')) return true return false }