import { type Context, Hono } from 'hono' import * as z from 'zod/mini' import * as Db from '../../../db/Db.js' import * as VerifiedTokenRequests from '../../../db/tables/verifiedTokenRequests.js' import * as Auth from '../../../internal/Auth.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' import { schema as tokenSchema } from '../../data/routes/verified-tokens.js' import type { Environment } from '../App.js' /** Largest accepted inline SVG logo (matches the admin upload limit). */ const maxLogoBytes = 256 * 1024 /** OpenAPI schemas owned by verified-token requests. */ export namespace schema { const chainId = z .number() .check( z.int(), z.describe('The Tempo chain id the token belongs to.'), z.meta({ examples: [4217] }), ) /** * An organization's verification request (response shape). `reviewedBy` is * deliberately absent: reviewer identity stays on the admin surface. */ export const VerifiedTokenRequest = OpenApi.component( Schema.describe( z.extend(tokenSchema.Input, { chainId, createdAt: z.iso .datetime() .check( z.describe('When the request was created (ISO 8601).'), z.meta({ examples: ['2026-01-01T00:00:00.000Z'] }), ), id: z .string() .check( z.describe('Opaque request resource id (`vtr_…`).'), z.meta({ examples: ['vtr_1a2b3c4d5e6f7g8h9j0k1m2n'] }), ), logo: z.optional( z .string() .check( z.describe('Inline SVG logo markup uploaded with the request, when present.'), z.meta({ examples: [''] }), ), ), note: z .nullable(z.string()) .check( z.describe('Requester note to the reviewers, or null.'), z.meta({ examples: ['We are the token issuer.', null] }), ), orgId: z .string() .check( z.describe('Requesting organization id (`org_…`).'), z.meta({ examples: ['org_1a2b3c4d5e6f7g8h9j0k1m2n'] }), ), requestedBy: z .string() .check( z.describe('User id that submitted the request.'), z.meta({ examples: ['usr_1a2b3c4d5e6f7g8h9j0k1m2n'] }), ), reviewNote: z .nullable(z.string()) .check( z.describe('Reviewer note shown when the request was denied, or null.'), z.meta({ examples: ['Logo URL is unreachable.', null] }), ), reviewedAt: z .nullable(z.iso.datetime()) .check( z.describe('When the request was reviewed (ISO 8601), or null while pending.'), z.meta({ examples: ['2026-01-02T00:00:00.000Z', null] }), ), status: z .enum(['approved', 'denied', 'pending']) .check(z.describe('Review status of the request.'), z.meta({ examples: ['pending'] })), updatedAt: z.iso .datetime() .check( z.describe('When the request was last changed (ISO 8601).'), z.meta({ examples: ['2026-01-01T00:00:00.000Z'] }), ), }), "An organization's request to add a token to the curated verified list.", ), 'VerifiedTokenRequest', ) /** * Create body. The shared token fields trust admin curation, so the * member-facing surface re-declares them with length caps. */ export const CreateBody = OpenApi.component( Schema.describe( z.extend(tokenSchema.Input, { chainId: Schema.ChainId, currency: z .string() .check( z.minLength(1), z.maxLength(10), z.describe('The currency label for this token, such as `USD`.'), z.meta({ examples: ['USD'] }), ), logo: z.optional( z.string().check( z.refine( (value) => /^<(\?xml|svg)[\s/>]/i.test(value.trimStart()), 'Logo must be SVG markup.', ), z.refine( (value) => new TextEncoder().encode(value).byteLength <= maxLogoBytes, `Logo exceeds the ${maxLogoBytes / 1024} KiB limit.`, ), z.describe('Inline SVG logo markup, published to the asset store on approval.'), z.meta({ examples: [''] }), ), ), logoUri: z.optional( z .url({ protocol: /^https$/ }) .check( z.maxLength(2048), z.describe('The 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.maxLength(100), z.describe('The token’s human-readable name.'), z.meta({ examples: ['USD Coin'] }), ), note: z.optional( z .string() .check( z.trim(), z.maxLength(500), z.describe('Optional note to the reviewers.'), z.meta({ examples: ['We are the token issuer.'] }), ), ), symbol: z .string() .check( z.minLength(1), z.maxLength(20), z.describe('The short ticker symbol wallets and apps show for this token.'), z.meta({ examples: ['USDC'] }), ), }), 'Fields for requesting verification of a token.', ), 'CreateVerifiedTokenRequestInput', ) /** Non-paginated list of an organization's requests. */ export const ListResponse = OpenApi.component( Schema.describe( z.object({ data: z .array(VerifiedTokenRequest) .check( z.describe("The organization's verification requests, newest first."), z.meta({ examples: [[]] }), ), }), "A non-paginated list of an organization's verified-token requests.", ), 'VerifiedTokenRequestList', ) /** Path parameters addressing an organization's requests. */ export const Params = z .object({ orgId: z .string() .check( z.describe('Organization id (`org_…`).'), z.meta({ examples: ['org_1a2b3c4d5e6f7g8h9j0k1m2n'] }), ), }) .check(z.describe("Path parameters for an organization's verified-token requests.")) /** Path parameters addressing one verification request. */ export const RequestParams = z .object({ orgId: z .string() .check( z.describe('Organization id (`org_…`).'), z.meta({ examples: ['org_1a2b3c4d5e6f7g8h9j0k1m2n'] }), ), requestId: z .string() .check( z.describe('Request resource id (`vtr_…`).'), z.meta({ examples: ['vtr_1a2b3c4d5e6f7g8h9j0k1m2n'] }), ), }) .check(z.describe('Path parameters for one verified-token request.')) /** Cancel confirmation. */ export const DeleteResponse = OpenApi.component( z .object({ id: z .string() .check( z.describe('Canceled request resource id.'), z.meta({ examples: ['vtr_1a2b3c4d5e6f7g8h9j0k1m2n'] }), ), }) .check(z.describe('Confirmation that a verified-token request was canceled.')), 'CancelVerifiedTokenRequestResponse', ) } const bodyValidation = { code: 'body_invalid', message: 'Check the request body and try again.', } as const const paramValidation = { code: 'param_invalid', message: 'Check the path parameters and try again.', } as const /** Maximum open (pending) requests per organization. */ const maxPending = 10 /** Mounts organization verified-token request operations. */ export function verifiedTokenRequests() { return new Hono() .post( '/v1/orgs/:orgId{org_[A-Za-z0-9_-]+}/verified-token-requests', Auth.policy({ session: true }), Auth.ensureOrg(), OpenApi.validate('param', schema.Params, paramValidation), OpenApi.validate('json', schema.CreateBody, bodyValidation), OpenApi.describeRoute({ operationId: 'createVerifiedTokenRequest', responses: OpenApi.responses({ errors: { 400: { codes: ['body_invalid', 'param_invalid'], description: 'Malformed API key, invalid path, or invalid body.', }, 404: { codes: ['organization_not_found'], description: 'No accessible organization was found.', }, 409: { codes: ['request_limit_reached', 'request_pending_exists', 'verified_symbol_exists', 'verified_token_exists'], // prettier-ignore description: 'The token is already verified or requested, or the organization is at its pending-request limit.', // prettier-ignore }, }, success: { description: 'Created request.', schema: schema.VerifiedTokenRequest }, }), summary: 'Create token request', tags: ['Verified Token Requests'], }), async (c) => { if (Auth.narrowAccess) return Auth.superAdminAccessError(c) if (Auth.narrowScope) return Auth.ensureOrgError(c) if (OpenApi.narrowValidation) return OpenApi.validationError(c, paramValidation) if (OpenApi.narrowValidation) return OpenApi.validationError(c, bodyValidation) const body = c.req.valid('json') try { const db = Db.get(c.get('db')) const orgId = Auth.org(c).id // Reject requests that could never be approved: the verified list // enforces address and case-insensitive symbol uniqueness per chain. const snapshot = await VerifiedTokens.read(db, body.chainId) if (snapshot?.byAddress.has(body.address)) return Response.error(c, { code: 'verified_token_exists', message: 'Token is already verified', status: 409, }) if (snapshot?.bySymbol.has(body.symbol.toLowerCase())) return Response.error(c, { code: 'verified_symbol_exists', message: 'A verified token already uses this symbol', status: 409, }) if ((await VerifiedTokenRequests.countPending(db, orgId)) >= maxPending) return Response.error(c, { code: 'request_limit_reached', message: `Organizations may have at most ${maxPending} pending requests`, status: 409, }) const record = await VerifiedTokenRequests.create(db, { address: body.address, chainId: body.chainId, currency: body.currency, decimals: body.decimals, ...(body.logo === undefined ? {} : { logo: body.logo }), ...(body.logoUri === undefined ? {} : { logoUri: body.logoUri }), name: body.name, ...(body.note ? { note: body.note } : {}), orgId, requestedBy: Auth.membership(c)?.userId ?? 'super_admin', symbol: body.symbol, }) if (!record) return Response.error(c, { code: 'request_pending_exists', message: 'Token already has a pending verification request', status: 409, }) return c.json( Response.validated( schema.VerifiedTokenRequest, VerifiedTokenRequests.toRequest(record), ), 200, ) } catch (cause) { return Response.upstream(c, cause) } }, ) .get( '/v1/orgs/:orgId{org_[A-Za-z0-9_-]+}/verified-token-requests', Auth.policy({ session: true }), Auth.ensureOrg(), OpenApi.validate('param', schema.Params, paramValidation), OpenApi.describeRoute({ operationId: 'listVerifiedTokenRequests', responses: OpenApi.responses({ errors: { 400: { codes: ['param_invalid'], description: 'Malformed API key or invalid path.' }, 404: { codes: ['organization_not_found'], description: 'No accessible organization was found.', }, }, success: { description: 'Verification requests.', schema: schema.ListResponse }, }), summary: 'List token requests', tags: ['Verified Token Requests'], }), async (c) => { if (Auth.narrowAccess) return Auth.superAdminAccessError(c) if (Auth.narrowScope) return Auth.ensureOrgError(c) if (OpenApi.narrowValidation) return OpenApi.validationError(c, paramValidation) try { const records = await VerifiedTokenRequests.list(Db.get(c.get('db')), Auth.org(c).id) return c.json( Response.validated(schema.ListResponse, { data: records.map(VerifiedTokenRequests.toRequest), }), 200, ) } catch (cause) { return Response.upstream(c, cause) } }, ) .delete( '/v1/orgs/:orgId{org_[A-Za-z0-9_-]+}/verified-token-requests/:requestId{vtr_[A-Za-z0-9_-]+}', Auth.policy({ session: true }), Auth.ensureOrg(), OpenApi.validate('param', schema.RequestParams, paramValidation), OpenApi.describeRoute({ operationId: 'cancelVerifiedTokenRequest', responses: OpenApi.responses({ errors: { 400: { codes: ['param_invalid'], description: 'Malformed API key or invalid path.' }, 404: { codes: ['organization_not_found', 'verified_token_request_not_found'], description: 'No accessible organization or verification request was found.', }, 409: { codes: ['request_not_pending'], description: 'Only pending requests can be canceled.', }, }, success: { description: 'Canceled request.', schema: schema.DeleteResponse }, }), summary: 'Cancel token request', tags: ['Verified Token Requests'], }), async (c) => { if (Auth.narrowAccess) return Auth.superAdminAccessError(c) if (Auth.narrowScope) return Auth.ensureOrgError(c) if (OpenApi.narrowValidation) return OpenApi.validationError(c, paramValidation) try { const db = Db.get(c.get('db')) const orgId = Auth.org(c).id const id = c.req.valid('param').requestId if (!(await VerifiedTokenRequests.remove(db, orgId, id))) { const existing = await VerifiedTokenRequests.get(db, id) if (existing && existing.orgId === orgId) return Response.error(c, { code: 'request_not_pending', message: 'Only pending requests can be canceled', status: 409, }) return notFound(c) } return c.json({ id }, 200) } catch (cause) { return Response.upstream(c, cause) } }, ) } function notFound(c: Context) { return Response.error(c, { code: 'verified_token_request_not_found', message: 'Verification request not found', status: 404, }) }