import { type Context, Hono } from 'hono' import { matchedRoutes } from 'hono/route' import { ZoneRpcAuthentication } from 'ox/tempo' import * as z from 'zod/mini' import * as Assets from '../../Assets.js' import type * as App from '../../App.js' import * as Auth from '../../internal/Auth.js' import * as EdgeCache from '../../internal/EdgeCache.js' import * as Path from '../../internal/Path.js' import * as Response from '../../internal/Response.js' import * as Schema from '../../internal/Schema.js' import * as Tidx from '../../internal/Tidx.js' import * as Timing from '../../internal/Timing.js' import * as Viem from '../../internal/Viem.js' import * as Scope from '../../Scope.js' import * as Metadata from '../metadata.js' import type * as FxOracle from './FxOracle.js' import * as ZoneSelection from './ZoneSelection.js' import { transactions as transactionActivities } from './routes/activities.js' import { addresses } from './routes/addresses.js' import { blocks, blocksByTimestampCompat } from './routes/blocks.js' import { coingecko } from './routes/coingecko.js' import * as Earn from './routes/earn.js' import { exchanges } from './routes/exchanges.js' import { feeAmm } from './routes/fee-amm.js' import { indexer } from './routes/indexer.js' import { receipts } from './routes/receipts.js' import { rpc } from './routes/rpc.js' import { tokenlist } from './routes/tokenlist.js' import { tokens } from './routes/tokens.js' import { transactions } from './routes/transactions.js' import { transfers } from './routes/transfers.js' import { verifiedTokens } from './routes/verified-tokens.js' import { schema as webhookSchema, webhooks } from './routes/webhooks.js' import { zones } from './routes/zones.js' /** * The chain-data route group extends app context with TIDX, assets, verified * tokens, and webhooks, and exposes indexed and RPC-backed reads. * * ```ts * App.create(options).route('/', data()) * ``` */ export function data(options: data.Options = {}) { const { assets, tidx } = options const configuredChainIds = typeof tidx === 'function' ? [] : Viem.configuredChainIds(tidx?.baseUrl) const webhook = options.webhook === false ? undefined : (options.webhook ?? { supportedChainIds: [Viem.chainId.mainnet, Viem.chainId.testnet], }) const dataRouteHandlers = new Set() const getTidx = Tidx.createGetClient({ tidx }) const addresses_routes = addresses({ fx: options.fx }) const earn_routes = Earn.earn({ fx: options.fx }) const indexer_routes = indexer() const transactionActivities_routes = transactionActivities({ fx: options.fx }) const indexerRouteHandlers = new Set(indexer_routes.routes.map((route) => route.handler)) const inferredZoneRouteHandlers = new Set([ ...addresses_routes.routes .filter((route) => route.path === '/v1/addresses/:address/activities') .map((route) => route.handler), ...transactionActivities_routes.routes.map((route) => route.handler), ]) const rpcOnlyRouteHandlers = new Set( earn_routes.routes .filter((route) => route.path === '/v1/earn/vaults/:vaultId/positions/:address') .map((route) => route.handler), ) const app = new Hono() // Publish the chain-data context vars for this group's handlers. Runs after // `App.create`'s middleware, so `getClient`/`getTidx` can key on the // resolved principal and `getAsset` can read `basePath` off context. .use('*', async (c, next) => { const getClient = c.get('getClient') const supportedChainIds = new Set([ ...c.get('supportedChainIds'), ...Object.keys(Tidx.url).map(Number), ...configuredChainIds, ]) c.set('getAsset', createGetAsset({ assets, basePath: c.get('basePath'), origin: new URL(c.req.url).origin })) // prettier-ignore c.set('getClient', (chainId) => getClient(chainId ?? c.get('chainId'))) c.set('getTidx', (chainId) => { const resolved = chainId ?? c.get('chainId') const client = getTidx(resolved, { principal: Auth.getPrincipal(c), zone: c.get('zones').get(resolved), }) return Tidx.observe(client, { record: (failure) => c.set('providerFailure', failure ? { ...failure, chainId: resolved } : undefined), time: (fn) => Timing.time(c, 'tidx', fn), }) }) c.set('supportedChainIds', supportedChainIds) c.set('tidx', tidx) c.set('verifiedTokens', options.verifiedTokens === false ? undefined : (options.verifiedTokens ?? {})) // prettier-ignore c.set('webhook', webhook) await next() }) // Resolve one chain for every data route before auth and handlers use it. .use('*', async (c, next) => { const defaultChainId = c.get('chainId') const supportedChainIds = c.get('supportedChainIds') const selection = resolveChainSelector({ c, defaultChainId, handlers: dataRouteHandlers, inferredZoneHandlers: inferredZoneRouteHandlers, indexerHandlers: indexerRouteHandlers, rpcOnlyHandlers: rpcOnlyRouteHandlers, }) if (!selection.matched) return next() if (selection.conflict || selection.invalid) return Response.error(c, { code: 'chain_id_invalid', message: selection.conflict ? 'Conflicting chain ids' : 'Invalid chain id', status: 400, }) const principal = Auth.getPrincipal(c) const rpc_config = c.get('rpc') const tidx_config = c.get('tidx') const zones = c.get('zones') const query = Earn.schema.getEarnVaultPosition.Query.safeParse( Object.fromEntries( Object.entries(c.req.queries()).map(([key, values]) => [ key, values.length === 1 ? values[0] : values, ]), ), ) const requiresTidx = selection.rpcOnly && query.success && query.data.asOf !== undefined // Raw passthroughs and current RPC-only reads need RPC. Historical // position reads and composed data routes require both upstreams. const narrowedChainIds = new Set( [...supportedChainIds].filter((chainId) => { const context = { chainId, principal, zone: zones.get(chainId) } const hasRpc = Viem.resolveRpc(rpc_config, context).url !== undefined const hasTidx = Tidx.resolve(tidx_config, context).baseUrl !== undefined if (selection.rpc || (selection.rpcOnly && !requiresTidx)) return hasRpc if (selection.indexer) return hasTidx return hasRpc && hasTidx }), ) c.set('supportedChainIds', narrowedChainIds) if (!narrowedChainIds.has(selection.chainId)) return Response.unsupportedChainId(c, selection.chainId, narrowedChainIds) if (selection.inferredZones) { if (principal?.type !== 'api_key') return Response.error(c, { code: 'api_key_forbidden', message: '`include=zones` requires an API key with readable Zone scopes.', status: 403, }) const zoneChainIds = ZoneSelection.readableChainIds({ parentChainId: selection.chainId, principal, rpc: rpc_config, tidx: tidx_config, zones, }) if (zoneChainIds.length === 0) return Response.error(c, { code: 'api_key_forbidden', message: 'The API key has no readable configured Zones.', status: 403, }) c.set('supportedChainIds', new Set([selection.chainId, ...zoneChainIds])) c.set('zoneChainIds', zoneChainIds) return next() } c.set('chainId', selection.chainId) return next() }) // Zone data requires an issued scope. Raw RPC may instead forward the // caller's Zone token to the node that enforces account scoping. .use('*', async (c, next) => { const zones = c.get('zones') if (zones.size === 0) return next() const selection = resolveChainSelector({ c, defaultChainId: c.get('chainId'), handlers: dataRouteHandlers, inferredZoneHandlers: inferredZoneRouteHandlers, indexerHandlers: indexerRouteHandlers, rpcOnlyHandlers: rpcOnlyRouteHandlers, }) if (!selection.matched) return next() if (selection.inferredZones) return next() const chainId = c.get('chainId') if (!zones.has(chainId)) return next() const principal = Auth.getPrincipal(c) const scopes = principal?.type === 'api_key' ? principal.apiKey.scopes : undefined const allowed = scopes?.includes(Scope.wildcard) || scopes?.includes(`zone:${chainId}:read`) || scopes?.includes(`zone:${chainId}:write`) || (selection.rpc && Boolean(c.req.header(ZoneRpcAuthentication.headerName)?.trim())) if (!allowed) return Response.error(c, { code: 'api_key_forbidden', message: selection.rpc ? `Chain ID ${chainId} is a Zone. Use an API key granting \`zone:${chainId}:read\` or \`zone:${chainId}:write\`, or pass a Zone token via \`X-Authorization-Token\`.` : `Chain ID ${chainId} is a Zone. Use an API key granting \`zone:${chainId}:read\` or \`zone:${chainId}:write\`.`, status: 403, }) await next() // Defense in depth for cache misses; pre-auth lookups are disabled by // the route eligibility predicate below. c.set('edgeCache', undefined) }) // Sandbox keys are testnet-only. A chain-scoped data route defaults to // mainnet, so a sandbox key that omits `chainId` (or targets mainnet) is // refused with a clear message telling the caller to pass a testnet // `chainId`. .use('*', async (c, next) => { const principal = Auth.getPrincipal(c) if (principal?.type !== 'api_key' || principal.environment !== 'sandbox') return next() const selection = resolveChainSelector({ c, defaultChainId: c.get('chainId'), handlers: dataRouteHandlers, inferredZoneHandlers: inferredZoneRouteHandlers, indexerHandlers: indexerRouteHandlers, rpcOnlyHandlers: rpcOnlyRouteHandlers, }) if (!selection.matched || selection.inferredZones || !Viem.isMainnet(c.get('chainId'))) return next() return Response.error(c, { code: 'api_key_forbidden', message: 'Sandbox API keys only support testnet. Pass a testnet `chainId`.', status: 403, }) }) // Serve static API assets (e.g. verified-token icons) from the configured // store; a no-op fall-through when no store is configured (logo URIs simply // go unresolved). .use('/assets/*', async (c, next) => { if (!assets) return next() const key = stripAssetPath(c.req.path, c.get('basePath')).replace(/^\/+/, '') const asset = key ? await assets.get(key) : undefined if (!asset) return next() return c.body(asset.body, 200, { 'Cache-Control': 'public, max-age=86400', 'Content-Type': asset.contentType, }) }) .route('/', addresses_routes) .route('/', blocks()) .route('/', earn_routes) .route('/', exchanges({ fx: options.fx })) .route('/', feeAmm()) .route('/', indexer_routes) .route('/', receipts({ fx: options.fx })) .route('/', tokenlist()) .route('/', tokens()) .route('/', transactions()) .route('/', transactionActivities_routes) .route('/', transfers({ fx: options.fx })) .route('/', verifiedTokens()) .route( '/', webhooks({ applicationEventTypes: webhook?.applicationEventTypes ?? [], enabled: options.webhook !== false, }), ) .route('/', zones()) .route('/', rpc()) .route('/', coingecko()) .route('/', blocksByTimestampCompat()) const edgeCacheEligible = (c: Context) => { const selection = resolveChainSelector({ c, defaultChainId: c.get('chainId'), handlers: dataRouteHandlers, inferredZoneHandlers: inferredZoneRouteHandlers, indexerHandlers: indexerRouteHandlers, rpcOnlyHandlers: rpcOnlyRouteHandlers, }) if (selection.matched && !selection.conflict && !selection.inferredZones && !selection.invalid) c.set('chainId', selection.chainId) return ( selection.matched && !selection.conflict && !selection.inferredZones && !selection.invalid && !c.get('zones').has(selection.chainId) ) } for (const route of app.routes) { if (route.method === 'ALL') continue dataRouteHandlers.add(route.handler) EdgeCache.setEligibility(route.handler, edgeCacheEligible) } return Metadata.attach(app, { ...openapi, supportedChainIds: [...Object.keys(Tidx.url).map(Number), ...configuredChainIds], }) } export declare namespace data { /** Options for the data route group. */ type Options = { /** Static API asset store for the current runtime (e.g. `Assets.cloudflareR2(...)`). Omit to skip static assets — token logo URIs go unresolved. */ assets?: Assets.Assets | undefined /** FX configuration for the valuation endpoint. */ fx?: Fx | undefined /** TIDX query client options. */ tidx?: Tidx.getClient.Tidx | undefined /** Verified-token feature configuration. Enabled by default (token rows live in `db`; empty until published); pass `false` to always serve an empty verified list. */ verifiedTokens?: VerifiedTokens | false | undefined /** Webhook feature configuration. Enabled by default (rows live in `db`); pass `false` to disable, leaving `/webhooks` mounted but returning `404` and hidden from the OpenAPI document. */ webhook?: Webhook | false | undefined } /** FX configuration for the valuation endpoint. */ type Fx = { /** FX rate oracle backing currency conversion. Defaults to the ECB daily reference rates (`FxOracle.ecb()`). */ oracle?: FxOracle.Oracle | undefined } /** Verified-token feature configuration. */ type VerifiedTokens = { /** * Per-isolate soft-refresh window (ms). Lookups serve from the in-memory * snapshot within it; after it, the next lookup re-checks the head version * and recompiles only on change. Defaults to 10_000. */ refreshMs?: number | undefined } /** Webhook feature configuration. */ type Webhook = App.Webhook } function resolveChainSelector(options: { c: Context defaultChainId: Viem.ChainId handlers: ReadonlySet inferredZoneHandlers: ReadonlySet indexerHandlers: ReadonlySet rpcOnlyHandlers: ReadonlySet }) { let matched = false let path: string | undefined let rpc = false let rpcOnly = false let indexer = false let inferredZonesAllowed = false for (const route of matchedRoutes(options.c)) { if (!options.handlers.has(route.handler)) continue matched = true if (options.inferredZoneHandlers.has(route.handler)) inferredZonesAllowed = true if (options.indexerHandlers.has(route.handler)) indexer = true if (options.rpcOnlyHandlers.has(route.handler)) rpcOnly = true const selector = pathChainSelector(route.path, options.c.req.path) if (selector.name === undefined) continue path = selector.value rpc = selector.name === 'chain' break } if (!matched) return { chainId: options.defaultChainId, conflict: false, inferredZones: false, indexer: false, invalid: false, matched: false, rpc: false, rpcOnly: false, } const search = new URL(options.c.req.url).searchParams const include = inferredZonesAllowed ? search.getAll('include').flatMap((value) => value.split(',').map((part) => part.trim())) : [] const inferredZones = include.includes('zones') const raw = [ ...(path === undefined ? [] : [path]), ...(rpc ? [] : search.getAll('chainId')), ...(rpc ? [] : search.getAll('chain_id')), ] const parsed = raw.map((value) => Schema.ChainId.safeParse(value)) const chainIds = [...new Set(parsed.flatMap((result) => (result.success ? [result.data] : [])))] return { chainId: chainIds[0] ?? options.defaultChainId, conflict: chainIds.length > 1, inferredZones, indexer, invalid: parsed.some((result) => !result.success), matched: true, rpc, rpcOnly, } } function pathChainSelector(routePath: string, requestPath: string) { const route = routePath.split('/') const request = requestPath.split('/') for (let index = 0; index < route.length; index++) { const segment = route[index] ?? '' for (const name of ['chainId', 'chain'] as const) { const prefix = `:${name}` if (!segment.startsWith(prefix)) continue const suffix = segment.slice(prefix.length) if (suffix !== '' && suffix !== '?' && !suffix.startsWith('{')) continue return { name, value: request[index] || undefined } } } return { name: undefined, value: undefined } } /** * The OpenAPI metadata this group owns: its operation tags and the outbound * `webhooks` block. Tagged onto the {@link data} instance so `App.create` * collects it; the webhooks block emits only when webhook routes are mounted. */ const openapi = { tags: [ { name: 'Activities', description: 'A readable feed of what an account did onchain.' }, { name: 'Balances', description: 'How much of each token an account holds.' }, { name: 'Blocks', description: 'The ordered batches of transactions making up the chain.' }, { name: 'Earn', description: 'Vaults that earn yield from assets deposited on Tempo.' }, { name: 'Exchange', description: "Tempo's built-in stablecoin exchange: pairs, swaps, orders, prices." }, // prettier-ignore { name: 'Fee AMM', description: 'Pools that convert stablecoins to pay fees.' }, { name: 'Tokens', description: 'TIP-20 token details, supply, and holders.' }, { name: 'Transactions', description: 'Transactions submitted to Tempo, and their receipts.' }, { name: 'Transfers', description: 'Token movements from executing transactions.', }, { name: 'Verified Tokens', description: 'A curated, trusted list of TIP-20 tokens.' }, { name: 'Webhooks', description: 'Get signed callbacks when onchain events happen.' }, { name: 'Zones', description: 'Private chain operations anchored to Tempo.' }, { name: 'Indexer', description: "Run read-only SQL queries against Tempo's indexed data." }, { name: 'RPC', description: 'Direct access to the chain over Ethereum JSON-RPC. Not part of the stable API contract; best-effort support only. No compatibility, latency, availability, or data-freshness guarantees. Breaking changes may happen with limited notice.' }, // prettier-ignore { name: 'CoinGecko', description: "Exchange data in CoinGecko's GeckoTerminal format." }, ], 'x-tagGroups': [ { name: 'Data API', tags: [ 'Activities', 'Balances', 'Blocks', 'Earn', 'Exchange', 'Fee AMM', 'Tokens', 'Transactions', 'Transfers', 'Verified Tokens', 'Webhooks', 'Zones', 'Indexer', 'RPC', 'MCP', 'CoinGecko', ], }, ], webhooks: webhooksBlock, } satisfies App.create.Metadata // Builds the outbound-delivery `webhooks` block. `io: 'output'` documents the // serialized shape; `unrepresentable: 'any'` emits `{}` for payload transforms // (e.g. lazy fee-token metadata) that have no JSON-Schema form. function webhooksBlock() { const envelope = z.toJSONSchema(webhookSchema.Envelope, { io: 'output', target: 'draft-2020-12', unrepresentable: 'any', }) delete envelope.$schema const header = (name: string, description: string) => ({ description, in: 'header' as const, name, required: true, schema: { type: 'string' as const }, }) return { event: { post: { description: 'When an event you subscribed to happens, Tempo sends this signed message to your webhook URL as an HTTP POST (the same shape is used by `POST /webhooks/:id/ping` so you can test your endpoint). Always verify the `tempo-signature` header before trusting the body, then reply with any 2xx status to confirm you received it. If your endpoint returns a non-2xx status, times out, or redirects, Tempo retries with increasing delays; an endpoint that keeps failing is eventually disabled.', parameters: [ header( 'tempo-signature', 'Signature proving the message really came from Tempo. An HMAC-SHA256 of the body using your webhook secret, formatted `t=,v1=`. Verify this before trusting the payload.', ), header( 'tempo-event-id', 'A unique, stable id for this event (`evt_…`). The same event may be delivered more than once, so use this id to skip duplicates.', ), header('tempo-event-type', 'What happened: `token:transfer`, `transaction:included`, `log:emitted`, `block:created`, `funding:deposit.updated`, `funding:transfer.updated`, or `ping`.'), // prettier-ignore ], requestBody: { content: { 'application/json': { schema: envelope } }, required: true, }, responses: { '2xx': { description: 'Delivery acknowledged.' } }, summary: 'Webhook event delivery', tags: ['Webhooks'], }, }, } } // Builds the per-request static-asset resolver. The store is keyed by // `:chainId/:path` (decoupled from the public `/assets/:chainId` URL) and // returns absolute URLs against the request origin. Without a store, every // lookup misses and callers fall back to undefined logo URIs. function createGetAsset(options: createGetAsset.Options): App.GetAsset { const { assets, basePath, origin } = options if (!assets) return () => undefined const getAsset: App.GetAsset = async (chainId, path) => { // `path` may be absolute (`Path.join` returns a leading slash); strip it so // the key is `:chainId/:path`, not `:chainId//:path`. const key = `${chainId}/${path.replace(/^\/+/, '')}` const asset = await assets.get(key) if (!asset) return undefined return { response: new globalThis.Response(asset.body, { headers: { 'Content-Type': asset.contentType }, }), uri: Assets.url({ basePath, chainId, origin, path }), } } const list = assets.list if (list) getAsset.list = async (chainId, path) => { const prefix = `${chainId}/${path.replace(/^\/+/, '')}` return (await list(prefix)).map((key) => ({ key, uri: Assets.url({ basePath, chainId, origin, path: key.slice(String(chainId).length + 1) }), })) } return getAsset } declare namespace createGetAsset { type Options = { assets?: Assets.Assets | undefined basePath: string origin: string } } // Strips the (possibly base-path-prefixed) `/assets/` segment so the remainder // is the store key path. function stripAssetPath(path: string, basePath: string) { for (const prefix of [`${Path.join(basePath, 'assets')}/`, '/assets/']) if (path.startsWith(prefix)) return `/${path.slice(prefix.length)}` return path }