import { type Context, Hono } from 'hono' import * as z from 'zod/mini' import * as OpenApi from '../../internal/OpenApi.js' import * as Response from '../../internal/Response.js' import * as Schema from '../../internal/Schema.js' import * as Db from '../../db/Db.js' import * as Organizations from '../../db/tables/organizations.js' import * as VerifiedTokenRequests from '../../db/tables/verifiedTokenRequests.js' import * as VerifiedTokens from '../../internal/VerifiedTokens.js' import { schema as readSchema } from '../../apps/data/routes/verified-tokens.js' import { schema as requestSchema } from '../../apps/management/routes/verified-token-requests.js' import type * as App from '../App.js' /** Required `chainId` query: admin mutations always address a chain explicitly. */ const ChainIdQuery = Schema.ChainId /** Zod schemas owned by the admin verified-tokens handler. */ export namespace schema { /** A curated verified TIP-20 token entry (shared with the public read API). */ export const Token = readSchema.Token /** Verified-token write/input fields without the server-derived `id`. */ export const Input = readSchema.Input /** Path parameters addressing a single verified token. */ export const Params = readSchema.Params /** Schemas for the listVerifiedTokens operation. */ export namespace listVerifiedTokens { /** Query parameters for the verified-token list. */ export const Query = z .strictObject({ chainId: ChainIdQuery }) .check(z.describe('Query parameters for the verified-token list.')) /** Response: the curated list and the current snapshot version. */ export const Response = Schema.describe( z.object({ data: z.array(Token).check(z.describe('The curated verified list for the chain.')), version: z .nullable(z.string()) .check(z.describe('Current snapshot version, or null when the list is empty.')), }), 'Curated verified tokens and the current snapshot version.', ) } /** Schemas for the createVerifiedToken operation. */ export namespace createVerifiedToken { /** Request body for creating a verified token. */ export const Body = z .extend(Input, { chainId: Schema.ChainId, logo: z.optional( z .string() .check(z.describe('Inline SVG logo markup, uploaded to the asset store on create.')), ), }) .check(z.describe('Request body for creating a verified token.')) /** Response after a successful create. */ export const Response = Schema.describe( z.object({ data: Token, version: z.string().check(z.describe('New snapshot version after the write.')), }), 'Created verified token and the new snapshot version.', ) } /** Schemas for the patchVerifiedToken operation. */ export namespace patchVerifiedToken { /** Query parameters for a verified-token patch. */ export const Query = z .strictObject({ chainId: ChainIdQuery }) .check(z.describe('Query parameters for a verified-token patch.')) /** Request body for partially updating a verified token. */ export const Body = z .partial(z.omit(Input, { address: true })) .check(z.describe('Request body for partially updating a verified token.')) /** Response after a successful patch. */ export const Response = createVerifiedToken.Response } /** Schemas for the deleteVerifiedToken operation. */ export namespace deleteVerifiedToken { /** Query parameters for a verified-token delete. */ export const Query = z .strictObject({ chainId: ChainIdQuery }) .check(z.describe('Query parameters for a verified-token delete.')) /** Response after a successful delete. */ export const Response = Schema.describe( z.object({ version: z.string().check(z.describe('New snapshot version after the removal.')), }), 'New snapshot version after a delete.', ) } /** Schemas for the uploadVerifiedTokenLogo operation. */ export namespace uploadVerifiedTokenLogo { /** Query parameters for a logo upload. */ export const Query = z .strictObject({ chainId: ChainIdQuery }) .check(z.describe('Query parameters for a logo upload.')) /** Response after a successful logo upload. */ export const Response = Schema.describe( z.object({ key: z.string().check(z.describe('Asset-store key the logo was written to.')), path: z.string().check(z.describe('Public path of the logo under the main API origin.')), }), 'Stored logo location.', ) } /** Schemas for the getVerifiedTokenLogo operation. */ export namespace getVerifiedTokenLogo { /** Query parameters for a logo read. */ export const Query = z .strictObject({ chainId: ChainIdQuery }) .check(z.describe('Query parameters for a logo read.')) } /** * A verification request with reviewer context: the management wire shape * plus the requesting org's name and the reviewing admin's email. */ export const Request = Schema.describe( z.extend(requestSchema.VerifiedTokenRequest, { orgName: z .nullable(z.string()) .check(z.describe("Requesting organization's current name, or null when deleted.")), reviewedBy: z .nullable(z.string()) .check(z.describe('Reviewing admin email, or null while pending.')), }), 'A verified-token request with requester organization context.', ) /** Path parameters addressing one verification request. */ export const RequestParams = z .object({ requestId: z .string() .check(z.regex(/^vtr_[A-Za-z0-9_-]+$/), z.describe('Request resource id (`vtr_…`).')), }) .check(z.describe('Path parameters for one verification request.')) /** Schemas for the listVerifiedTokenRequests operation. */ export namespace listVerifiedTokenRequests { /** Query parameters for the request queue. */ export const Query = z .strictObject({ status: z ._default(z.enum(['approved', 'denied', 'pending']), 'pending') .check(z.describe('Review status to filter by.')), }) .check(z.describe('Query parameters for the verification-request queue.')) /** Response: requests in the selected status, oldest first. */ export const Response = Schema.describe( z.object({ data: z.array(Request).check(z.describe('Requests in the selected status, oldest first.')), }), 'Verified-token requests in review-queue order.', ) } /** Schemas for the denyVerifiedTokenRequest operation. */ export namespace denyVerifiedTokenRequest { /** Request body for a denial. */ export const Body = z .object({ reviewNote: z.optional( z .string() .check(z.trim(), z.maxLength(500), z.describe('Reason shown to the requester.')), ), }) .check(z.describe('Request body for denying a verification request.')) } /** Response shared by the approve and deny operations. */ export namespace reviewVerifiedTokenRequest { /** The reviewed request. */ export const Response = Schema.describe( z.object({ data: Request }), 'The reviewed verified-token request.', ) } /** Schemas for the replaceVerifiedTokens operation. */ export namespace replaceVerifiedTokens { /** Query parameters for a bulk replace. */ export const Query = z .strictObject({ chainId: ChainIdQuery }) .check(z.describe('Query parameters for a bulk replace.')) /** Request body for replacing a chain's entire verified list. */ export const Body = z .object({ tokens: z.array(Input).check(z.describe('The full verified list to publish for the chain.')), // prettier-ignore }) .check(z.describe('Request body for replacing a chain verified list.')) /** Response after a successful bulk replace. */ export const Response = Schema.describe( z.object({ data: z.array(Token).check(z.describe('The published verified list.')), version: z.string().check(z.describe('New snapshot version after the replace.')), }), 'Published verified list and the new snapshot version.', ) } } /** * Management routes for the curated verified-token list, backed by the app * database (snapshot rows). Mutations accept an optional `If-Match` * precondition (`412` on mismatch). * * - `POST /` creates a token (`409` on a duplicate address or symbol). * - `PUT /` replaces a chain's entire list (seed/rollback). * - `PATCH /:address` partially updates a token (`404` if absent). * - `DELETE /:address` removes a token (`404` if absent). * - `PUT /:address/logo` uploads a curated logo SVG (gated on `assets`). * - `GET /requests` lists organization verification requests. * - `POST /requests/:requestId/approve|deny` reviews a request. */ export function verifiedTokens() { return ( new Hono() .get( '/', OpenApi.validate('query', schema.listVerifiedTokens.Query, { code: 'query_invalid', message: 'Invalid query parameters', }), OpenApi.describeRoute({ operationId: 'listVerifiedTokens', responses: OpenApi.responses({ success: { description: 'Curated verified tokens.', schema: schema.listVerifiedTokens.Response, }, }), summary: 'List verified tokens', tags: ['Verified Tokens'], }), async (c) => { const db = Db.get(c.get('db')) const { chainId } = c.req.valid('query') const snapshot = await VerifiedTokens.read(db, chainId) return c.json( Response.validated(schema.listVerifiedTokens.Response, { data: snapshot?.list ?? [], version: snapshot?.version ?? null, }), ) }, ) .post( '/', OpenApi.validate('json', schema.createVerifiedToken.Body, { code: 'body_invalid', message: 'Invalid request body', }), OpenApi.describeRoute({ operationId: 'createVerifiedToken', responses: OpenApi.responses({ errors: { 409: 'Duplicate address or symbol.', 413: 'Logo exceeds the size limit.', 415: 'Logo is not valid SVG.', }, success: { description: 'Created verified token.', schema: schema.createVerifiedToken.Response, }, }), summary: 'Create verified token', tags: ['Verified Tokens'], }), async (c) => { const db = Db.get(c.get('db')) const { logo, ...body } = c.req.valid('json') try { if (logo !== undefined) { const assets = c.get('assets') if (!assets?.put) return assetsNotEnabled(c) const svg = encodeLogo(logo) const duplicate = await findDuplicate(db, body) if (duplicate) throw duplicate await assets.put(logoKey(body.chainId, body.address), { body: svg, contentType: 'image/svg+xml', }) } const { snapshot, token } = await VerifiedTokens.create(db, body) return c.json( Response.validated(schema.createVerifiedToken.Response, { data: token, version: snapshot.version, }), ) } catch (cause) { return mutationError(c, cause) } }, ) .put( '/', OpenApi.validate('query', schema.replaceVerifiedTokens.Query, { code: 'query_invalid', message: 'Invalid query parameters', }), OpenApi.validate('json', schema.replaceVerifiedTokens.Body, { code: 'body_invalid', message: 'Invalid request body', }), OpenApi.describeRoute({ operationId: 'replaceVerifiedTokens', responses: OpenApi.responses({ errors: { 409: 'Duplicate address or symbol.', 412: 'Version precondition failed.', }, success: { description: 'Published verified list.', schema: schema.replaceVerifiedTokens.Response, }, }), summary: 'Replace verified tokens', tags: ['Verified Tokens'], }), async (c) => { const db = Db.get(c.get('db')) const { chainId } = c.req.valid('query') const { tokens } = c.req.valid('json') try { const { snapshot } = await VerifiedTokens.replace(db, chainId, tokens, { ...ifMatch(c), }) return c.json( Response.validated(schema.replaceVerifiedTokens.Response, { data: snapshot.list, version: snapshot.version, }), ) } catch (cause) { return mutationError(c, cause) } }, ) .patch( '/:address', OpenApi.validate('param', schema.Params, { code: 'address_invalid', message: 'Invalid token address', }), OpenApi.validate('query', schema.patchVerifiedToken.Query, { code: 'query_invalid', message: 'Invalid query parameters', }), OpenApi.validate('json', schema.patchVerifiedToken.Body, { code: 'body_invalid', message: 'Invalid request body', }), OpenApi.describeRoute({ operationId: 'patchVerifiedToken', responses: OpenApi.responses({ errors: { 404: 'Verified token not found (or feature disabled).', 409: 'Duplicate symbol.', 412: 'Version precondition failed.', }, success: { description: 'Updated verified token.', schema: schema.patchVerifiedToken.Response, }, }), summary: 'Update verified token', tags: ['Verified Tokens'], }), async (c) => { const db = Db.get(c.get('db')) const { address } = c.req.valid('param') const { chainId } = c.req.valid('query') const body = c.req.valid('json') try { const { snapshot, token } = await VerifiedTokens.patch(db, chainId, address, body, { ...ifMatch(c), }) return c.json( Response.validated(schema.patchVerifiedToken.Response, { data: token, version: snapshot.version, }), ) } catch (cause) { return mutationError(c, cause) } }, ) .delete( '/:address', OpenApi.validate('param', schema.Params, { code: 'address_invalid', message: 'Invalid token address', }), OpenApi.validate('query', schema.deleteVerifiedToken.Query, { code: 'query_invalid', message: 'Invalid query parameters', }), OpenApi.describeRoute({ operationId: 'deleteVerifiedToken', responses: OpenApi.responses({ errors: { 404: 'Verified token not found (or feature disabled).', 412: 'Version precondition failed.', }, success: { description: 'Removed; returns the new snapshot version.', schema: schema.deleteVerifiedToken.Response, }, }), summary: 'Delete verified token', tags: ['Verified Tokens'], }), async (c) => { const db = Db.get(c.get('db')) const { address } = c.req.valid('param') const { chainId } = c.req.valid('query') try { const { snapshot } = await VerifiedTokens.remove(db, chainId, address, { ...ifMatch(c), }) return c.json( Response.validated(schema.deleteVerifiedToken.Response, { version: snapshot.version, }), ) } catch (cause) { return mutationError(c, cause) } }, ) .get( '/:address/logo', OpenApi.validate('param', schema.Params, { code: 'address_invalid', message: 'Invalid token address', }), OpenApi.validate('query', schema.getVerifiedTokenLogo.Query, { code: 'query_invalid', message: 'Invalid query parameters', }), async (c) => { const assets = c.get('assets') const { address } = c.req.valid('param') const { chainId } = c.req.valid('query') const logo = await assets?.get(logoKey(chainId, address)) if (!logo) return Response.error(c, { code: 'logo_not_found', message: 'Logo not found.', status: 404, }) c.header('Content-Type', logo.contentType) return c.body(logo.body) }, ) .put( '/:address/logo', OpenApi.validate('param', schema.Params, { code: 'address_invalid', message: 'Invalid token address', }), OpenApi.validate('query', schema.uploadVerifiedTokenLogo.Query, { code: 'query_invalid', message: 'Invalid query parameters', }), OpenApi.describeRoute({ operationId: 'uploadVerifiedTokenLogo', responses: OpenApi.responses({ errors: { 404: 'Asset uploads are not enabled.', 413: 'Logo exceeds the size limit.', 415: 'Body is not `image/svg+xml`.', }, success: { description: 'Stored logo location.', schema: schema.uploadVerifiedTokenLogo.Response, }, }), summary: 'Upload verified-token logo', tags: ['Verified Tokens'], }), async (c) => { const assets = c.get('assets') if (!assets?.put) return assetsNotEnabled(c) const { address } = c.req.valid('param') const { chainId } = c.req.valid('query') const contentType = c.req.header('content-type')?.split(';')[0]?.trim().toLowerCase() if (contentType !== 'image/svg+xml') return Response.error(c, { code: 'unsupported_media_type', message: 'Logo uploads must be `image/svg+xml`.', status: 415, }) const body = await c.req.arrayBuffer() if (body.byteLength === 0) return Response.error(c, { code: 'body_invalid', message: 'Logo body is empty.', status: 400, }) if (body.byteLength > maxLogoBytes) return Response.error(c, { code: 'payload_too_large', message: `Logo exceeds the ${maxLogoBytes / 1024} KiB limit.`, status: 413, }) // Keyed as the main API's loader reads it (curated icon takes precedence). const key = logoKey(chainId, address) await assets.put(key, { body, contentType: 'image/svg+xml' }) return c.json( Response.validated(schema.uploadVerifiedTokenLogo.Response, { key, path: `/assets/${key}`, }), ) }, ) // Request-review routes live under the literal `/requests` segment; a // future single-segment `GET`/`POST /:address` route would shadow it. .get( '/requests', OpenApi.validate('query', schema.listVerifiedTokenRequests.Query, { code: 'query_invalid', message: 'Invalid query parameters', }), OpenApi.describeRoute({ operationId: 'listVerifiedTokenRequests', responses: OpenApi.responses({ success: { description: 'Verification requests.', schema: schema.listVerifiedTokenRequests.Response, }, }), summary: 'List token requests', tags: ['Verified Tokens'], }), async (c) => { const db = Db.get(c.get('db')) const { status } = c.req.valid('query') const records = await VerifiedTokenRequests.listByStatus(db, status) return c.json( Response.validated(schema.listVerifiedTokenRequests.Response, { data: records.map((record) => ({ ...VerifiedTokenRequests.toRequest(record), orgName: record.orgName, })), }), ) }, ) .post( '/requests/:requestId/approve', OpenApi.validate('param', schema.RequestParams, { code: 'param_invalid', message: 'Invalid request id', }), OpenApi.describeRoute({ operationId: 'approveVerifiedTokenRequest', responses: OpenApi.responses({ errors: { 404: 'Verification request not found.', 409: 'Request already reviewed, or the token conflicts with the verified list.', }, success: { description: 'Approved request; the token is now verified.', schema: schema.reviewVerifiedTokenRequest.Response, }, }), summary: 'Approve token request', tags: ['Verified Tokens'], }), async (c) => { const db = Db.get(c.get('db')) const { requestId } = c.req.valid('param') try { const record = await VerifiedTokenRequests.get(db, requestId) if (!record) return requestNotFound(c) if (record.status !== 'pending') return requestNotPending(c) // Skip creation when the token is already listed: the token create // and the request review commit separately, so a crash between them // leaves the request pending; re-approving converges here. const snapshot = await VerifiedTokens.read(db, record.chainId) if (!snapshot?.byAddress.has(record.address)) { // A requester-uploaded logo publishes to the asset store first, // so an upload failure leaves no token behind. if (record.logo !== null) { const assets = c.get('assets') if (!assets?.put) return assetsNotEnabled(c) await assets.put(logoKey(record.chainId, record.address), { body: encodeLogo(record.logo), contentType: 'image/svg+xml', }) } await VerifiedTokens.create(db, { address: record.address, chainId: record.chainId, currency: record.currency, decimals: record.decimals, ...(record.logoUri === null ? {} : { logoUri: record.logoUri }), name: record.name, symbol: record.symbol, }) } const updated = await VerifiedTokenRequests.review(db, requestId, { reviewedBy: c.get('identity')!.email, status: 'approved', }) if (!updated) return requestNotPending(c) return c.json( Response.validated(schema.reviewVerifiedTokenRequest.Response, { data: await withOrgName(db, updated), }), ) } catch (cause) { return mutationError(c, cause) } }, ) .post( '/requests/:requestId/deny', OpenApi.validate('param', schema.RequestParams, { code: 'param_invalid', message: 'Invalid request id', }), OpenApi.validate('json', schema.denyVerifiedTokenRequest.Body, { code: 'body_invalid', message: 'Invalid request body', }), OpenApi.describeRoute({ operationId: 'denyVerifiedTokenRequest', responses: OpenApi.responses({ errors: { 404: 'Verification request not found.', 409: 'Request already reviewed.', }, success: { description: 'Denied request.', schema: schema.reviewVerifiedTokenRequest.Response, }, }), summary: 'Deny token request', tags: ['Verified Tokens'], }), async (c) => { const db = Db.get(c.get('db')) const { requestId } = c.req.valid('param') const body = c.req.valid('json') try { const record = await VerifiedTokenRequests.get(db, requestId) if (!record) return requestNotFound(c) const updated = await VerifiedTokenRequests.review(db, requestId, { ...(body.reviewNote ? { reviewNote: body.reviewNote } : {}), reviewedBy: c.get('identity')!.email, status: 'denied', }) if (!updated) return requestNotPending(c) return c.json( Response.validated(schema.reviewVerifiedTokenRequest.Response, { data: await withOrgName(db, updated), }), ) } catch (cause) { return mutationError(c, cause) } }, ) ) } /** Attaches the requesting org's current name to a request's wire shape. */ async function withOrgName(db: Db.Db, record: VerifiedTokenRequests.Record) { const org = await Organizations.get(db, record.orgId) return { ...VerifiedTokenRequests.toRequest(record), orgName: org?.name ?? null } } function requestNotFound(c: Context) { return Response.error(c, { code: 'verified_token_request_not_found', message: 'Verification request not found', status: 404, }) } function requestNotPending(c: Context) { return Response.error(c, { code: 'request_not_pending', message: 'Request has already been reviewed', status: 409, }) } /** Largest accepted logo upload (SVG icons are tiny; this is generous). */ const maxLogoBytes = 256 * 1024 /** Asset-store key the main API's loader reads for a token's curated icon. */ function logoKey(chainId: number, address: string): string { return `${chainId}/icons/${address.toLowerCase()}` } // Thrown by `encodeLogo` for an invalid inline SVG; mapped to its status below. class LogoError extends Error { constructor( readonly status: 400 | 413 | 415, readonly code: string, message: string, ) { super(message) } } // Validates inline SVG markup and returns its bytes; throws `LogoError` when the // body is empty, not SVG, or over the size limit. function encodeLogo(logo: string): ArrayBuffer { const trimmed = logo.trimStart() if (trimmed.length === 0) throw new LogoError(400, 'body_invalid', 'Logo body is empty.') if (!/^<(\?xml|svg)[\s/>]/i.test(trimmed)) throw new LogoError(415, 'unsupported_media_type', 'Logo must be SVG markup.') const bytes = new TextEncoder().encode(logo) if (bytes.byteLength > maxLogoBytes) throw new LogoError(413, 'payload_too_large', `Logo exceeds the ${maxLogoBytes / 1024} KiB limit.`) // prettier-ignore return bytes.buffer as ArrayBuffer } // Returns the duplicate-address/symbol error a create would throw, or undefined, // so the logo upload can be skipped before it overwrites an existing icon. async function findDuplicate( db: Db.Db, token: { address: string; chainId: VerifiedTokens.ChainId; symbol: string }, ): Promise { const snapshot = await VerifiedTokens.read(db, token.chainId) if (!snapshot) return undefined if (snapshot.byAddress.has(token.address.toLowerCase())) return new VerifiedTokens.DuplicateAddressError({ address: token.address, chainId: token.chainId }) // prettier-ignore if (snapshot.bySymbol.has(token.symbol.toLowerCase())) return new VerifiedTokens.DuplicateSymbolError({ chainId: token.chainId, symbol: token.symbol }) return undefined } function assetsNotEnabled(c: Context) { return Response.error(c, { code: 'assets_not_enabled', message: 'Asset uploads are not enabled for this deployment.', status: 404, }) } // Reads the optional `If-Match` precondition, stripping ETag-style quotes. function ifMatch(c: Context): { ifMatch?: string } { const header = c.req.header('If-Match') if (!header) return {} return { ifMatch: header.replace(/^"|"$/g, '') } } // Maps `VerifiedTokens` write failures to the API's JSON error envelope. function mutationError(c: Context, cause: unknown) { if (cause instanceof LogoError) return Response.error(c, { code: cause.code, message: cause.message, status: cause.status }) if (cause instanceof VerifiedTokens.DuplicateAddressError) return Response.error(c, { code: 'duplicate_address', message: cause.message, status: 409 }) if (cause instanceof VerifiedTokens.DuplicateSymbolError) return Response.error(c, { code: 'duplicate_symbol', message: cause.message, status: 409 }) if (cause instanceof VerifiedTokens.NotFoundError) return Response.error(c, { code: 'verified_token_not_found', message: 'Verified token not found', status: 404, }) if (cause instanceof VerifiedTokens.VersionMismatchError) return Response.error(c, { code: 'version_mismatch', message: cause.message, status: 412 }) return Response.upstream(c, cause) }