import { type Context, Hono } 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 VerifiedTokens from '../../../internal/VerifiedTokens.js' /** Zod schemas owned by the verified-tokens resource. */ export namespace schema { // Shared, validated entry fields (everything but the address). Reused across // the response entry, the create body, and the bulk-replace body. const fields = { currency: z .string() .check( z.minLength(1), z.describe( 'The currency label for this token, such as `USD` for USD-denominated stablecoins.', ), z.meta({ examples: ['USD'] }), ), 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: [6] }), ), logoUri: z .optional(z.url({ protocol: /^https$/ })) .check( z.describe('The curated HTTPS URL for this token’s logo image, when one is set.'), z.meta({ examples: ['https://assets.tempo.xyz/icons/usdc.svg'] }), ), name: z .string() .check( z.minLength(1), z.describe('The token’s human-readable name.'), z.meta({ examples: ['USD Coin'] }), ), symbol: z .string() .check( z.minLength(1), z.describe('The short ticker symbol wallets and apps show for this token.'), z.meta({ examples: ['USDC'] }), ), } /** * Verified-token write/input fields, without the server-derived `id`. Reused * as the base for the create body, the patch body, and the bulk-replace body * so clients never supply `id` (it is always derived from `address`). */ export const Input = Schema.describe( z.object({ address: Schema.Address.check(z.describe('The TIP-20 token contract address on Tempo.')), currency: fields.currency, decimals: fields.decimals, logoUri: fields.logoUri, name: fields.name, symbol: fields.symbol, }), 'The fields used to create or update a curated verified TIP-20 token entry.', ) /** * A curated verified TIP-20 token entry (response shape). The `id` is the * stable resource id, always derived server-side from `address`. */ export const Token = Schema.describe( z.extend(Input, { id: z .string() .check(z.describe('A stable resource ID for this token, equal to its contract address.')), }), 'One curated, verified TIP-20 token that wallets and apps can present as trusted.', ) /** Path parameters addressing a single verified token. */ export const Params = z .object({ address: Schema.TokenAddress.check(z.describe('The TIP-20 token contract address on Tempo.')), }) .check(z.describe('Path parameters for looking up one verified token.')) /** Schemas for the listVerifiedTokens operation. */ export namespace listVerifiedTokens { /** Query parameters for the verified-token list. */ export const Query = z .strictObject({ chainId: Schema.ChainIdQuery, currency: z.optional(z.string()).check( z.meta({ examples: VerifiedTokens.currencies }), z.describe('Only include verified tokens denominated in this currency, such as `USD`. Matching is case-insensitive.'), // prettier-ignore ), }) .check(z.describe('Query parameters for listing verified tokens.')) /** Non-paginated list of curated verified tokens. */ export const Response = Schema.describe( z.object({ data: z.array(Token).check(z.describe('The curated verified TIP-20 tokens.')) }), 'A non-paginated list of curated, verified TIP-20 tokens.', ) } /** Schemas for the getVerifiedTokenCurrencies operation. */ export namespace getVerifiedTokenCurrencies { /** Query parameters for the verified-token currencies endpoint. */ export const Query = z .strictObject({ chainId: Schema.ChainIdQuery }) .check(z.describe('Query parameters for listing currencies used by verified tokens.')) /** Sorted, distinct currencies present in a chain's verified list. */ export const Response = Schema.describe( z.object({ data: z .array(z.string()) .check( z.describe('The sorted, unique currency labels used by tokens in the verified list.'), ), }), 'The distinct currencies present in a chain’s verified token list.', ) } /** Schemas for the getVerifiedToken operation. */ export namespace getVerifiedToken { /** Query parameters for a single verified token. */ export const Query = z .strictObject({ chainId: Schema.ChainIdQuery }) .check(z.describe('Query parameters for looking up one verified token.')) } } /** * Mounts the `/verified-tokens` resource (public reads). Mutations live in the * admin API (`tapimo/admin`), which manages the same store these reads serve * their snapshots from. */ export function verifiedTokens() { return new Hono() .get( '/v1/verified-tokens', Auth.policy({ apiKey: { scopes: ['data:read'] }, mpp: true, public: true }), OpenApi.validate('query', schema.listVerifiedTokens.Query, { code: 'query_invalid', message: 'Invalid query parameters', }), OpenApi.describeRoute({ hide: true, operationId: 'listVerifiedTokens', responses: OpenApi.responses({ success: { description: 'The curated verified TIP-20 tokens.', schema: schema.listVerifiedTokens.Response, }, }), summary: 'List verified tokens', tags: ['Verified Tokens'], }), // The shared edge cache is URL-keyed and cannot include the snapshot version. EdgeCache.setEligibility( Cache.response({ cacheControl: Cache.policies.stable, name: 'tempo-api:verified-tokens:v1', key: async (c) => { const base = Cache.urlKey(c, schema.listVerifiedTokens.Query) const query = schema.listVerifiedTokens.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}` }, }), () => false, ), 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 data = query.currency ? (snapshot.byCurrency.get(query.currency.toLowerCase()) ?? []) : snapshot.list return c.json(Response.validated(schema.listVerifiedTokens.Response, { data }), 200) } catch (cause) { return Response.upstream(c, cause) } }, ) .get( '/v1/verified-tokens/currencies', Auth.policy({ apiKey: { scopes: ['data:read'] }, mpp: true, public: true }), OpenApi.validate('query', schema.getVerifiedTokenCurrencies.Query, { code: 'query_invalid', message: 'Invalid query parameters', }), OpenApi.describeRoute({ hide: true, operationId: 'getVerifiedTokenCurrencies', responses: OpenApi.responses({ success: { description: 'The distinct currencies used by verified tokens.', schema: schema.getVerifiedTokenCurrencies.Response, }, }), summary: 'List verified currencies', tags: ['Verified Tokens'], }), // The shared edge cache is URL-keyed and cannot include the snapshot version. EdgeCache.setEligibility( Cache.response({ cacheControl: Cache.policies.stable, name: 'tempo-api:verified-tokens:v1', key: async (c) => { const base = Cache.urlKey(c, schema.getVerifiedTokenCurrencies.Query) const query = schema.getVerifiedTokenCurrencies.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}` }, }), () => false, ), 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) return c.json( Response.validated(schema.getVerifiedTokenCurrencies.Response, { data: snapshot.currencies, }), 200, ) } catch (cause) { return Response.upstream(c, cause) } }, ) .get( '/v1/verified-tokens/:address', Auth.policy({ apiKey: { scopes: ['data:read'] }, mpp: true, public: true }), OpenApi.validate('param', schema.Params, { code: 'address_invalid', message: 'Invalid token address', }), OpenApi.validate('query', schema.getVerifiedToken.Query, { code: 'query_invalid', message: 'Invalid query parameters', }), OpenApi.describeRoute({ hide: true, operationId: 'getVerifiedToken', responses: OpenApi.responses({ errors: { 404: 'No verified token was found for this address.' }, success: { description: 'One verified TIP-20 token from Tempo’s curated list.', schema: schema.Token, }, }), summary: 'Get verified token', tags: ['Verified Tokens'], }), // The shared edge cache is URL-keyed and cannot include the snapshot version. EdgeCache.setEligibility( Cache.response({ cacheControl: Cache.policies.stable, name: 'tempo-api:verified-tokens:v1', key: async (c) => { const base = Cache.urlKey(c, schema.getVerifiedToken.Query) const query = schema.getVerifiedToken.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}` }, }), () => false, ), async (c) => { if (Auth.narrowAccess) return Auth.paidAccessError(c) if (OpenApi.narrowValidation) return OpenApi.validationError(c, { code: 'address_invalid', message: 'Invalid token address', }) if (OpenApi.narrowValidation) return OpenApi.validationError(c, { code: 'query_invalid', message: 'Invalid query parameters', }) const { address } = 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.byAddress.get(address.toLowerCase()) if (!token) return notFound(c) return c.json(Response.validated(schema.Token, token), 200) } catch (cause) { return Response.upstream(c, cause) } }, ) } function notFound(c: Context) { return Response.error(c, { code: 'verified_token_not_found', message: 'Verified token not found', status: 404, }) }