import { type Context, Hono } from 'hono' import * as z from 'zod/mini' import * as Earn from '../../apps/data/routes/earn.js' import * as Db from '../../db/Db.js' import * as core_EarnVaults from '../../db/tables/earnVaults.js' import * as Cursor from '../../internal/Cursor.js' import * as EarnVaults from '../../internal/EarnVaults.js' import * as OpenApi from '../../internal/OpenApi.js' import * as Path from '../../internal/Path.js' import * as Response from '../../internal/Response.js' import * as Schema from '../../internal/Schema.js' import * as VerifiedTokens from '../../internal/VerifiedTokens.js' import * as Viem from '../../internal/Viem.js' import type * as App from '../App.js' /** Zod schemas owned by the admin verified-vault handler. */ export namespace schema { /** One Earn vault, shared with the public detail API. */ export const Vault = Earn.schema.Vault /** One registry-curated Earn vault, shared with the public verified list. */ export const VerifiedVault = Earn.schema.VerifiedVault /** Schemas for the listVerifiedEarnVaults operation. */ export namespace listVerifiedEarnVaults { /** Query parameters for verified Earn vaults, without the public rate window. */ export const Query = Earn.schema.getVerifiedEarnVaults.BaseQuery /** Paginated verified Earn vaults. */ export const Response = Earn.schema.getVerifiedEarnVaults.Response } /** Schemas for the getVerifiedEarnVault operation. */ export namespace getVerifiedEarnVault { /** Path parameters identifying one verified vault. */ export const Params = Earn.schema.getEarnVault.Params /** Query parameters selecting the vault chain and included fields. */ export const Query = Earn.schema.getEarnVault.BaseQuery /** One Earn vault resolved for registry management. */ export const Response = Vault } /** Schemas for the putVerifiedEarnVault operation. */ export namespace putVerifiedEarnVault { /** Curated fields used to verify and store one Earn vault. */ export const Body = z .strictObject({ chainId: Schema.ChainId, description: z .optional( z.nullable( z.string().check( z.describe('Curated vault description.'), z.meta({ examples: [ 'Bridge test PATHUSD deposited into the canonical synchronous Earn stack.', ], }), ), ), ) .check(z.describe('Curated vault description, or null when absent.')), label: z .optional( z .string() .check( z.minLength(1), z.describe('Curated vault label.'), z.meta({ examples: ['btPATHUSD Earn'] }), ), ) .check(z.describe('Curated vault label, defaulting to the share-token name.')), privateInputTokens: z .optional(z.array(Schema.Address)) .check(z.describe('Tokens accepted for private deposits.')), privateOutputTokens: z .optional(z.array(Schema.Address)) .check(z.describe('Tokens supported for private redemptions.')), slug: z .optional( z .string() .check( z.minLength(1), z.regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/), z.describe('Stable curated vault slug.'), z.meta({ examples: ['btpathusd-earn'] }), ), ) .check(z.describe('Custom vault slug, generated from the label when omitted.')), vaultAddress: Schema.Address.check( z.describe('Earn vault contract address.'), z.meta({ examples: ['0x4f94590b636f5878bce585e82379de81e1ec174f'] }), ), zones: z .optional(z.array(EarnVaults.schema.ZoneRoute)) .check(z.describe('Curated private Zone routes.')), }) .check(z.describe('Curated fields used to verify and store one Earn vault.')) /** The verified vault after the write. */ export const Response = VerifiedVault } /** Schemas for the deleteVerifiedEarnVault operation. */ export namespace deleteVerifiedEarnVault { /** Path parameters identifying one verified vault. */ export const Params = Earn.schema.getEarnVault.Params /** Query parameters selecting the vault chain. */ export const Query = z .strictObject({ chainId: Schema.ChainId }) .check(z.describe('Query parameters selecting one verified Earn vault.')) } } /** Admin routes for reading and managing verified Earn vaults. */ export function earnVaults() { return new Hono() .get( '/', OpenApi.validate('query', schema.listVerifiedEarnVaults.Query, { code: 'query_invalid', message: 'Invalid query parameters', }), OpenApi.describeRoute({ operationId: 'listVerifiedEarnVaults', responses: OpenApi.responses({ success: { description: 'A page of verified Earn vaults.', schema: schema.listVerifiedEarnVaults.Response, }, }), summary: 'List verified vaults', tags: ['Earn Vaults'], }), async (c) => { const query = c.req.valid('query') try { const chainId = query.chainId ?? Viem.defaultChainId const data = await listVerifiedVaults(c, { ...query, chainId }) return c.json(Response.validated(schema.listVerifiedEarnVaults.Response, data), 200) } catch (cause) { return Response.upstream(c, cause) } }, ) .get( '/:vaultId', OpenApi.validate('param', schema.getVerifiedEarnVault.Params, { code: 'vault_id_invalid', message: 'Invalid vault id', }), OpenApi.validate('query', schema.getVerifiedEarnVault.Query, { code: 'query_invalid', message: 'Invalid query parameters', }), OpenApi.describeRoute({ operationId: 'getVerifiedEarnVault', responses: OpenApi.responses({ errors: { 404: { codes: ['earn_vault_not_found'], description: 'The verified vault was not found.', }, }, success: { description: 'One verified Earn vault.', schema: schema.getVerifiedEarnVault.Response, }, }), summary: 'Get verified vault', tags: ['Earn Vaults'], }), async (c) => { const { vaultId } = c.req.valid('param') const query = c.req.valid('query') const chainId = query.chainId ?? Viem.defaultChainId try { const data = await getVault(c, { chainId, include: query.include, vaultAddress: vaultId, }) return c.json(Response.validated(schema.getVerifiedEarnVault.Response, data), 200) } catch (cause) { if (cause instanceof Earn.NotFoundError || Earn.isIncompatible(cause)) return notFound(c) return Response.upstream(c, cause) } }, ) .put( '/', OpenApi.validate('json', schema.putVerifiedEarnVault.Body, { code: 'body_invalid', message: 'Invalid request body', }), OpenApi.describeRoute({ operationId: 'putVerifiedEarnVault', responses: OpenApi.responses({ errors: { 404: { codes: ['earn_vault_not_found'], description: 'The vault was not found on the selected chain.', }, 409: { codes: ['earn_vault_verification_failed'], description: 'The contracts do not form a supported Earn vault.', }, }, success: { description: 'The verified Earn vault.', schema: schema.putVerifiedEarnVault.Response, }, }), summary: 'Put verified vault', tags: ['Earn Vaults'], }), async (c) => { const input = c.req.valid('json') const db = Db.get(c.get('db')) const existing = await core_EarnVaults.get(db, { chainId: input.chainId, vaultAddress: input.vaultAddress, }) try { const discovery = await EarnVaults.resolve({ getClient: c.get('getClient'), input: { chainId: input.chainId, vaultAddress: input.vaultAddress, }, zones: c.get('zones'), }) if (!discovery.engine.type) return verificationFailed(c, 'The vault uses an unsupported engine type.') const privateInputTokens = input.privateInputTokens ?? existing?.privateInputTokens ?? [] const privateOutputTokens = input.privateOutputTokens ?? existing?.privateOutputTokens ?? [] const zoneRoutes = input.zones ?? existing?.zones ?? [] const hasPrivateInputs = privateInputTokens.length > 0 const hasPrivateOutputs = privateOutputTokens.length > 0 if (hasPrivateInputs !== hasPrivateOutputs) return verificationFailed(c, 'Private input and output tokens must both be configured.') const resolvedZoneRoutes = await EarnVaults.resolveZoneRoutes({ discovery, getClient: c.get('getClient'), routes: zoneRoutes, zones: c.get('zones'), }) const capabilities = resolvedZoneRoutes.length > 0 ? EarnVaults.addZoneRouteCapabilities(discovery.capabilities) : discovery.capabilities if (hasPrivateInputs && !capabilities.privateRouting) return verificationFailed(c, 'The chain does not support private Earn routing.') await core_EarnVaults.upsert(db, { chainId: input.chainId, description: input.description === undefined ? (existing?.description ?? null) : input.description, label: input.label ?? existing?.label ?? discovery.shareToken.name, privateInputTokens, privateOutputTokens, ...(input.slug !== undefined ? { slug: input.slug } : {}), vaultAddress: input.vaultAddress, zones: zoneRoutes, }) const data = await getVault(c, { chainId: input.chainId, include: ['access', 'capabilities', 'zone', 'zones'], vaultAddress: input.vaultAddress, }) return c.json(Response.validated(schema.putVerifiedEarnVault.Response, data), 200) } catch (cause) { return mutationError(c, cause) } }, ) .delete( '/:vaultId', OpenApi.validate('param', schema.deleteVerifiedEarnVault.Params, { code: 'vault_id_invalid', message: 'Invalid vault id', }), OpenApi.validate('query', schema.deleteVerifiedEarnVault.Query, { code: 'query_invalid', message: 'Invalid query parameters', }), OpenApi.describeRoute({ operationId: 'deleteVerifiedEarnVault', responses: { 204: { description: 'The vault was removed from the verified registry.' }, 400: OpenApi.standardError(400, 'Invalid request.', [ 'query_invalid', 'vault_id_invalid', ]), 401: OpenApi.standardError(401, 'Admin authentication required.'), 404: OpenApi.standardError(404, 'Verified Earn vault not found.', [ 'earn_vault_not_found', ]), 429: OpenApi.standardError(429, 'Rate limited.'), 500: OpenApi.standardError(500, 'Internal server error.'), }, summary: 'Remove verified vault', tags: ['Earn Vaults'], }), async (c) => { const { vaultId } = c.req.valid('param') const { chainId } = c.req.valid('query') const record = await core_EarnVaults.remove(Db.get(c.get('db')), { chainId, vaultAddress: vaultId, }) if (!record) return notFound(c) return c.body(null, 204) }, ) } async function listVerifiedVaults( c: Context, options: listVerifiedVaults.Options, ) { const db = Db.get(c.get('db')) const [records, snapshot] = await Promise.all([ core_EarnVaults.list(db, { chainId: options.chainId }), VerifiedTokens.read(db, options.chainId), ]) const decoded = options.cursor ? Cursor.decode(options.cursor, ['address']) : undefined const cursor = decoded?.[0]?.toString().toLowerCase() const candidates = records.filter( (record) => !cursor || record.vaultAddress.toLowerCase() > cursor, ) const matches: listVerifiedVaults.Match[] = [] for (const record of candidates) { const discovered = await resolveVault(c, { chainId: options.chainId, include: discoveryInclude(options), vaultAddress: record.vaultAddress, }) const { discovery, zoneRoutes } = await resolveVaultRoutes(c, { capability: options.capability, discovery: discovered, include: options.include, record, }) if (!Earn.matchesVaultFilters(discovery, options)) continue matches.push({ discovery, record, zoneRoutes }) if (matches.length > options.limit) break } const page = matches.slice(0, options.limit) const data = await Promise.all( page.map((match) => serializeVault(c, { ...match, chainId: options.chainId, include: options.include, snapshot, }), ), ) const last = matches[options.limit - 1] return { data, nextCursor: matches.length > options.limit && last ? Cursor.encode([last.record.vaultAddress]) : null, } } declare namespace listVerifiedVaults { type Match = { /** Live onchain vault state. */ discovery: EarnVaults.Discovery /** Curated registry row. */ record: core_EarnVaults.Record /** Verified private Zone routes selected for serialization. */ zoneRoutes: readonly EarnVaults.ResolvedZoneRoute[] } type Options = z.output & { /** Parent chain containing the vaults. */ chainId: z.output } } async function getVault(c: Context, options: getVault.Options) { const db = Db.get(c.get('db')) const record = await core_EarnVaults.get(db, options) try { const [discovered, snapshot] = await Promise.all([ resolveVault(c, { ...options, include: discoveryInclude({ capability: [], include: options.include }), }), VerifiedTokens.read(db, options.chainId), ]) const { discovery, zoneRoutes } = await resolveVaultRoutes(c, { capability: [], discovery: discovered, include: options.include, record, }) return serializeVault(c, { ...options, discovery, record, snapshot, zoneRoutes }) } catch (cause) { if (Earn.isIncompatible(cause)) throw new Earn.NotFoundError() throw cause } } declare namespace getVault { type Options = { /** Parent chain containing the vault. */ chainId: z.output /** Optional embedded token fields. */ include: z.output['include'] /** Earn vault address. */ vaultAddress: z.output } } function resolveVault(c: Context, options: resolveVault.Options) { return EarnVaults.resolve({ getClient: c.get('getClient'), include: options.include, input: { chainId: options.chainId, vaultAddress: options.vaultAddress, }, zones: c.get('zones'), }) } declare namespace resolveVault { type Options = { /** Parent chain containing the vault. */ chainId: z.output /** Onchain vault fields required by the request. */ include: readonly EarnVaults.resolve.Include[] /** Earn vault address. */ vaultAddress: z.output } } async function resolveVaultRoutes( c: Context, options: resolveVaultRoutes.Options, ): Promise { const { discovery, record } = options if (!record || record.zones.length === 0) return { discovery, zoneRoutes: [] } const includesRoutes = options.include.includes('zone') || options.include.includes('zones') const needsCapabilities = options.include.includes('capabilities') || options.capability.includes('privateRouting') || options.capability.includes('routerSwaps') if (!includesRoutes && !needsCapabilities) return { discovery, zoneRoutes: [] } const zoneRoutes = await EarnVaults.resolveZoneRoutes({ discovery, getClient: c.get('getClient'), routes: record.zones, zones: c.get('zones'), }) const capabilities = discovery.capabilities if (needsCapabilities && !capabilities) throw new Error('Earn vault capabilities were not resolved.') return { discovery: capabilities ? { ...discovery, capabilities: EarnVaults.addZoneRouteCapabilities(capabilities) } : discovery, zoneRoutes: includesRoutes ? zoneRoutes : [], } } declare namespace resolveVaultRoutes { type Options = { capability: z.output['capability'] discovery: EarnVaults.Discovery include: z.output['include'] record?: core_EarnVaults.Record | undefined } type Result = { discovery: EarnVaults.Discovery zoneRoutes: readonly EarnVaults.ResolvedZoneRoute[] } } async function serializeVault( c: Context, options: serializeVault.Options, ): Promise> { const [assetToken, shareToken] = await Promise.all([ serializeToken(c, { ...options, metadata: options.discovery.assetToken }), serializeToken(c, { ...options, metadata: options.discovery.shareToken }), ]) return Earn.serializeVault({ // Rate and valuation enrichment need indexer and FX access the admin app // does not carry, so its query schema cannot select either field. apy: null, assetToken, discovery: options.discovery, include: options.include, instantLiquidityValue: null, record: options.record, shareToken, tvl: null, zoneRoutes: options.zoneRoutes, zones: c.get('zones'), }) } declare namespace serializeVault { type Options = { /** Parent chain containing the vault. */ chainId: z.output /** Live onchain vault state. */ discovery: EarnVaults.Discovery /** Optional embedded token fields. */ include: z.output['include'] /** Curated registry row, when verified. */ record?: core_EarnVaults.Record | undefined /** Verified-token snapshot, when configured. */ snapshot: VerifiedTokens.Snapshot | null /** Verified private Zone routes selected for serialization. */ zoneRoutes: readonly EarnVaults.ResolvedZoneRoute[] } } function discoveryInclude( options: discoveryInclude.Options, ): readonly EarnVaults.resolve.Include[] { const includeCapabilities = options.include.includes('capabilities') || options.include.includes('zone') || options.capability.length > 0 return [ ...(options.include.includes('access') ? (['access'] as const) : []), ...(includeCapabilities ? (['capabilities'] as const) : []), ] } declare namespace discoveryInclude { type Options = Pick< z.output, 'capability' | 'include' > } async function serializeToken( c: Context, options: serializeToken.Options, ): Promise> { const address = Schema.Address.parse(options.metadata.address.toLowerCase()) const logoUri = options.include.includes('token.logoUri') ? await resolveLogoUri(c, { address, chainId: options.chainId }) : undefined return Response.validated(Earn.schema.Vault.shape.assetToken, { address, currency: options.metadata.currency, decimals: options.metadata.decimals, id: address, logoUri, name: options.metadata.name, symbol: options.metadata.symbol, verified: options.snapshot?.byAddress.has(address) ?? false, }) } declare namespace serializeToken { type Options = { /** Parent chain containing the token. */ chainId: z.output /** Optional embedded token fields. */ include: z.output['include'] /** Onchain token metadata. */ metadata: EarnVaults.Discovery['assetToken'] /** Verified-token snapshot, when configured. */ snapshot: VerifiedTokens.Snapshot | null } } async function resolveLogoUri( c: Context, options: resolveLogoUri.Options, ): Promise { const asset = await c.get('assets')?.get(`${options.chainId}/icons/${options.address}`) if (!asset) return undefined const uri = new URL( Path.join(c.get('basePath'), 'verified-tokens', options.address, 'logo'), c.req.url, ) uri.searchParams.set('chainId', String(options.chainId)) return uri.toString() } declare namespace resolveLogoUri { type Options = { /** Token address. */ address: z.output /** Parent chain containing the token. */ chainId: z.output } } function mutationError(c: Context, cause: unknown) { if (cause instanceof EarnVaults.InvalidRegistryError) return Response.error(c, { code: 'body_invalid', details: [{ message: cause.message }], message: 'Invalid verified Earn vault', status: 400, }) if (cause instanceof EarnVaults.VerificationError && cause.result.status === 'mismatch') return Response.error(c, { code: 'earn_vault_verification_failed', details: cause.result.issues.map((issue) => ({ message: issue.message })), message: 'Earn vault verification failed', status: 409, }) if (cause instanceof Earn.NotFoundError) return notFound(c) if (cause instanceof EarnVaults.NotFoundError) return notFound(c, cause.message) return Response.upstream(c, cause) } function notFound(c: Context, message = 'Verified Earn vault not found') { return Response.error(c, { code: 'earn_vault_not_found', message, status: 404, }) } function verificationFailed(c: Context, message: string) { return Response.error(c, { code: 'earn_vault_verification_failed', details: [{ message }], message: 'Earn vault verification failed', status: 409, }) }