import { Hono, type Context } from 'hono' import * as z from 'zod/mini' import type * as App from '../../../App.js' import * as Auth from '../../../internal/Auth.js' import * as Cache from '../../../internal/Cache.js' import * as EdgeCache from '../../../internal/EdgeCache.js' import * as OpenApi from '../../../internal/OpenApi.js' import * as Response from '../../../internal/Response.js' import * as Schema from '../../../internal/Schema.js' import * as Timing from '../../../internal/Timing.js' import * as VerifiedTokens from '../../../internal/VerifiedTokens.js' import * as Viem from '../../../internal/Viem.js' import * as Tokens from './tokens.js' /** Zod schemas owned by the tokenlist resource. */ export namespace schema { /** A single token entry in the verified token list format (no `extensions`). */ export const Token = z .object({ chainId: z .number() .check( z.int(), z.positive(), z.describe('The Tempo chain ID where this token lives.'), z.meta({ examples: [4217] }), ), address: Schema.tokenAddress(Tokens.tokenExample.address).check( z.describe('The TIP-20 token contract address on Tempo.'), ), name: z .string() .check( z.describe('The token’s human-readable name.'), z.meta({ examples: [Tokens.tokenExample.name] }), ), symbol: z .string() .check( z.describe('The short ticker symbol wallets and apps show for this token.'), z.meta({ examples: [Tokens.tokenExample.symbol] }), ), decimals: z .number() .check( z.int(), z.gte(0), z.lte(255), z.describe( 'The number of decimal places the token uses; Tempo stablecoins typically use 6.', ), z.meta({ examples: [Tokens.tokenExample.decimals] }), ), logoURI: z.optional(z.string()).check( z.describe('The curated HTTPS URL for this token’s logo image, when one is set.'), z.meta({ examples: [Tokens.tokenExample.logoUri], }), ), }) .check(z.describe('One token entry in the standard verified token list format.')) /** Semantic version object required by the token-list schema. */ export const Version = z .object({ major: z .number() .check( z.int(), z.nonnegative(), z.describe('The major number in the token list’s semantic version.'), z.meta({ examples: [1] }), ), minor: z .number() .check( z.int(), z.nonnegative(), z.describe('The minor number in the token list’s semantic version.'), z.meta({ examples: [0] }), ), patch: z .number() .check( z.int(), z.nonnegative(), z.describe('The patch number in the token list’s semantic version.'), z.meta({ examples: [0] }), ), }) .check(z.describe('The token list’s semantic version.')) /** Schemas for the getTokenList operation. */ export namespace getTokenList { /** Query parameters for the token list. */ export const Query = z .strictObject({ chainId: Schema.ChainIdQuery }) .check(z.describe('Query parameters for fetching the verified token list.')) /** A Uniswap-compatible token list of all verified tokens on a chain. */ export const Response = z .object({ name: z .string() .check( z.describe('The human-readable name of this token list.'), z.meta({ examples: ['Tempo Verified Tokens'] }), ), timestamp: z.iso .datetime() .check( z.describe('The ISO 8601 timestamp when this token list was last updated.'), z.meta({ examples: ['2024-01-01T00:00:00Z'] }), ), version: Version.check(z.describe('The token list’s semantic version.')), tokens: z.array(Token).check(z.describe('The verified TIP-20 tokens on this chain.')), }) .check(z.describe('A Uniswap Token Lists-compatible list of Tempo’s verified TIP-20 tokens.')) } } /** * Mounts the hidden `/tokenlist` resource: every verified token for a chain, * served in the standard Uniswap token-lists format (minus `extensions`) so * wallets and other token-list consumers can load Tempo's verified set * directly. Reads the same verified-token snapshot the `/v1/verified-tokens` * endpoints serve. */ export function tokenlist() { return new Hono().get( '/v1/tokenlist', Auth.policy({ apiKey: { scopes: ['data:read'] }, mpp: true, public: true }), OpenApi.validate('query', schema.getTokenList.Query, { code: 'query_invalid', message: 'Invalid query parameters', }), OpenApi.describeRoute({ description: 'Returns every verified token on a Tempo chain in the standard Uniswap Token Lists format, so wallets and apps can show trusted names, symbols, and logos.', operationId: 'getTokenList', responses: OpenApi.responses({ success: { description: 'A standard token list of Tempo’s verified TIP-20 tokens.', example: { name: 'Tempo', timestamp: '2026-06-17T00:00:00.000Z', version: { major: 1, minor: 0, patch: 2 }, tokens: [ { address: Tokens.tokenExample.address, chainId: Tokens.tokenExampleChainId, decimals: Tokens.tokenExample.decimals, logoURI: Tokens.tokenExample.logoUri, name: Tokens.tokenExample.name, symbol: Tokens.tokenExample.symbol, }, ], }, schema: schema.getTokenList.Response, }, }), summary: 'Get token list', tags: ['Verified Tokens'], }), // Anonymous callers accept the route's five-minute freshness window. // Credentialed reads keep snapshot-version invalidation. EdgeCache.setEligibility( Cache.response({ cacheControl: Cache.policies.stable, name: 'tempo-api:tokenlist:v1', key: async (c) => { const base = Cache.urlKey(c, schema.getTokenList.Query) const query = schema.getTokenList.Query.parse( Object.fromEntries(new URL(c.req.url).searchParams), ) const chainId = query.chainId ?? c.get('chainId') const { version } = await VerifiedTokens.snapshot(c, chainId) return `${base}:v:${version}` }, }), anonymousEdgeCache, ), 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') const chainId = query.chainId ?? c.get('chainId') try { const snapshot = await VerifiedTokens.snapshot(c, chainId) const logos = await getTokenLogos(c, { chainId, tokens: snapshot.list }) const tokens = snapshot.list.map((token) => { const logoURI = logos.get(token.address) ?? token.logoUri return { chainId, address: token.address, name: token.name, symbol: token.symbol, decimals: token.decimals, ...(logoURI === undefined ? {} : { logoURI }), } }) const list = { name: Viem.chains.find((chain) => chain.id === chainId)?.name ?? `Tempo ${chainId}`, timestamp: snapshot.updatedAt, version: { major: 1, minor: 0, patch: tokens.length }, tokens, } return c.json(Response.validated(schema.getTokenList.Response, list), 200) } catch (cause) { return Response.upstream(c, cause) } }, ) } function anonymousEdgeCache(c: Context) { return ( !c.req.header('authorization') && !c.req.header('cookie') && !c.req.header('tempo-api-key') && !c.req.header('x-api-key') ) } async function getTokenLogos( c: Context, options: getTokenLogos.Options, ): Promise> { return Timing.time(c, 'token_logos', async () => { const getAsset = c.get('getAsset') const listAssets = getAsset.list if (!listAssets) { const entries = await Promise.all( options.tokens.map( async (token) => [ token.address, await Tokens.getTokenLogo(c, { address: token.address, chainId: options.chainId, }).catch(() => undefined), ] as const, ), ) const logos = new Map() for (const [address, uri] of entries) if (uri) logos.set(address, uri) return logos } // Logos are optional metadata; an asset-store outage must not hide the // verified token list itself. Snapshot logo URIs remain available below. const assets = await listAssets(options.chainId, 'icons/').catch(() => []) const uris = new Map(assets.map((asset) => [asset.key, asset.uri])) return new Map( options.tokens.flatMap((token) => { const uri = uris.get(`${options.chainId}/icons/${token.address}`) return uri ? [[token.address, uri] as const] : [] }), ) }) } declare namespace getTokenLogos { type Options = { /** Tempo chain containing the token assets. */ chainId: number /** Verified tokens whose curated logo locations should be resolved. */ tokens: readonly Pick[] } }