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,
})
}