import { Hono, type Context } from 'hono' import { Value as core_Value } from 'ox' import { Actions } from 'viem/tempo' import * as z from 'zod/mini' import type * as App from '../../../App.js' import * as Auth from '../../../internal/Auth.js' import * as Cache from '../../../internal/Cache.js' import * as Cursor from '../../../internal/Cursor.js' import * as OpenApi from '../../../internal/OpenApi.js' import * as Response from '../../../internal/Response.js' import * as Schema from '../../../internal/Schema.js' import * as Timing from '../../../internal/Timing.js' import * as VerifiedTokens from '../../../internal/VerifiedTokens.js' import * as FxOracle from '../FxOracle.js' import * as FeeAmm from './fee-amm.js' import * as Tokens from './tokens.js' import * as Valuation from './valuation.js' // TIP-20 tokens are minted by the TIP-20 factory and live at deterministic // `0x20c0…`-prefixed addresses (e.g. pathUSD `0x20c0…0000`). The indexer's // balance sources include every Transfer-emitting contract, so discovery // filters on this prefix to keep non-TIP-20 tokens out of results. const tip20AddressPrefix = '0x20c0' /** Zod schemas owned by the balance handlers. */ export namespace schema { /** Schemas for the getAddressBalances operation. */ export namespace getAddressBalances { /** Path parameters for address balance requests. */ export const Params = z .object({ address: Schema.Address.check( z.describe('The account address whose token balances you want to list.'), ), }) .check(z.describe('Path parameters for listing an account’s token balances.')) /** Query parameters for address balance requests. */ export const Query = z .strictObject({ chainId: Schema.ChainIdQuery, currency: z .optional(z.string()) .check( z.meta({ examples: VerifiedTokens.currencies }), z.describe( 'Only include verified tokens denominated in this currency (e.g. `USD`). ' + 'Case-insensitive. Implies `verified=true` because the balances ' + 'snapshot does not store currency for unverified tokens. ' + 'Unknown currency strings are accepted but match no rows.', ), ), cursor: Schema.Cursor, feeEligible: z .optional(Schema.booleanQuery()) .check( z.describe( 'When `true`, include only tokens eligible to pay transaction fees on Tempo: ' + 'verified tokens that have a Fee AMM pool, plus the default fee token ' + '(`pathUSD`). Use this to avoid a second `/fee-amm/pools` query when you ' + 'only want fee-payable holdings.', ), z.meta({ examples: [true] }), ), include: Schema.totalCountInclude, limit: Schema.Limit, page: Schema.Page, 'valuation.currency': Schema.Denomination.check( z.describe( 'When present, include each holding’s nominal value in this denomination. ' + 'Case-insensitive and must be priced by the configured FX oracle.', ), ), verified: z .optional(Schema.booleanQuery()) .check( z.describe( 'When `true`, include only tokens from Tempo’s curated verified token list.', ), z.meta({ examples: [true] }), ), }) .check( ...Schema.pageChecks(), z.describe('Query parameters for listing an account’s token balances.'), ) /** A TIP-20 balance with raw and formatted amounts. Unresolvable token metadata omits the row. */ export const Balance = OpenApi.component( Schema.describe( z.object({ amount: z .string() .check( z.regex(/^\d+$/), z.describe( 'A non-negative whole number, given as a decimal string so very large token amounts keep full precision. Expressed in the smallest unit of the token.', ), z.meta({ examples: ['1500000'] }), ), currency: z .string() .check( z.describe( 'The currency label for this balance, such as `USD` for a USD-denominated token.', ), z.meta({ examples: ['USD'] }), ), decimals: z .number() .check( z.int(), z.nonnegative(), z.describe( 'The number of decimal places used to convert this balance between base units and human-readable form.', ), z.meta({ examples: [6] }), ), feeEligible: z .optional(z.boolean()) .check( z.describe( 'Whether this token can be used to pay transaction fees on Tempo: `true` when ' + 'the token is verified and has a Fee AMM pool, or is the default fee token ' + '(`pathUSD`). Lets you identify fee-payable holdings without a separate ' + '`/fee-amm/pools` query. Omitted (best-effort) when the fee-token lookup ' + 'is unavailable.', ), z.meta({ examples: [true] }), ), formatted: z .string() .check( z.describe( 'The same balance in human-readable decimal form, using this balance’s `decimals`.', ), z.meta({ examples: ['1.5'] }), ), id: z .string() .check( z.describe( 'A stable resource ID for this balance, equal to the token contract address.', ), z.meta({ examples: ['0x20c0000000000000000000008f5425160ebe5525'] }), ), token: Tokens.schema.Token, valuation: z .optional(z.nullable(Valuation.schema.Value)) .check( z.describe( 'The holding’s nominal value when `valuation.currency` is requested, or `null` when ' + 'the token is unverified, its display currency has no rate, or rates were unavailable.', ), ), }), 'One TIP-20 token balance held by this account, with both raw and human-readable amounts.', ), 'Balance', ) /** Page-level resources: opt-in counts plus valuation rate provenance. */ export const Meta = OpenApi.component( z .object({ valuation: z .optional(Valuation.schema.Pricing) .check( z.describe( 'Rate provenance for row valuations. Present when conversion rates were ' + 'consulted; absent when every value was identity-valued or rates were unavailable.', ), ), totalCount: z.optional(Schema.TotalCount), totalCountCapped: z.optional(Schema.TotalCountCapped), }) .check(z.describe('Page-level resources attached to this response.')), 'BalanceListMeta', ) /** Page of TIP-20 token balances held by an address, ordered by amount descending. */ export const Response = OpenApi.component( z .object({ data: z .array(Balance) .check( z.describe('The balances in this page, ordered from largest to smallest amount.'), ), meta: z .optional(Meta) .check( z.describe('Page-level resources: opt-in counts and valuation rate provenance.'), ), nextCursor: Schema.NextCursor, }) .check(z.describe('A page of TIP-20 token balances held by this account.')), 'BalanceList', ) } /** Schemas for the getAddressBalance operation. */ export namespace getAddressBalance { /** Path parameters for a single address balance request. */ export const Params = z .object({ address: Schema.Address.check( z.describe('The account address whose token balance you want to read.'), ), token: Schema.TokenAddress.check(z.describe('The TIP-20 token contract address.')), }) .check(z.describe('Path parameters for reading one account token balance.')) /** Query parameters for a single address balance request. */ export const Query = z .strictObject({ chainId: Schema.ChainIdQuery, 'valuation.currency': Schema.Denomination.check( z.describe( 'When present, include the holding’s nominal value in this denomination. ' + 'Case-insensitive and must be priced by the configured FX oracle.', ), ), }) .check(z.describe('Query parameters for reading one account token balance.')) /** A single TIP-20 token balance held by an address. */ export const Response = getAddressBalances.Balance } } /** * Creates address-scoped balance handlers, mounted under the `/addresses` * composer. Exposes the held-balance page and individual token balances. */ export function addresses(options: addresses.Options = {}) { const oracle = options.fx?.oracle ?? FxOracle.ecb() return new Hono() .get( '/v1/addresses/:address/balances', Auth.policy({ apiKey: { scopes: ['data:read'] }, mpp: true, public: true }), OpenApi.validate('param', schema.getAddressBalances.Params, { code: 'address_invalid', message: 'Invalid account address', }), OpenApi.validate('query', schema.getAddressBalances.Query, { code: 'query_invalid', message: 'Invalid query parameters', }), OpenApi.describeRoute({ description: 'Lists how much of each TIP-20 token an account holds, ordered from largest to smallest balance. Amounts are returned in raw base units and human-readable form. Pass `valuation.currency` to include nominal values.', operationId: 'getAddressBalances', responses: OpenApi.responses({ errors: { 400: { codes: [ 'address_invalid', 'chain_id_invalid', 'chain_id_unsupported', 'query_invalid', ], }, 502: 'Could not read balance or token data from an upstream service.', }, success: { description: 'A page of token balances for this account.', schema: schema.getAddressBalances.Response, }, }), summary: 'List address balances', tags: ['Balances'], }), Cache.response({ cacheControl: Cache.policies.noStore, name: 'tempo-api:addresses:v1', key: (c) => Cache.urlKey(c, schema.getAddressBalances.Query), }), async (c) => { if (Auth.narrowAccess) return Auth.paidAccessError(c) if (OpenApi.narrowValidation) return OpenApi.validationError(c, { code: 'address_invalid', message: 'Invalid request parameters', }) if (OpenApi.narrowValidation) return OpenApi.validationError(c, { code: 'query_invalid', message: 'Invalid request parameters', }) const { address } = c.req.valid('param') const query = c.req.valid('query') try { const chainId = query.chainId ?? c.get('chainId') // `currency` only makes sense against verified tokens, so it implies // `verified=true` from the caller's perspective. const verified = query.verified || query.currency !== undefined const feeEligible = query.feeEligible ?? false const balances = await getAddressBalances(c, { address, chainId, currency: query.currency, cursor: query.cursor, denomination: query['valuation.currency'], feeEligible, includeTotalCount: query.include.includes('totalCount'), limit: query.limit, oracle, page: query.page, verified, }) return c.json(Response.validated(schema.getAddressBalances.Response, balances), 200) } catch (cause) { if (cause instanceof Valuation.UnsupportedDenominationError) return Response.error(c, { code: 'query_invalid', message: cause.message, status: 400 }) return Response.upstream(c, cause, { page: query.page }) } }, ) .get( '/v1/addresses/:address/balances/:token', Auth.policy({ apiKey: { scopes: ['data:read'] }, mpp: true, public: true }), OpenApi.validate('param', schema.getAddressBalance.Params, { code: 'address_invalid', message: 'Invalid address or token address', }), OpenApi.validate('query', schema.getAddressBalance.Query, { code: 'query_invalid', message: 'Invalid query parameters', }), OpenApi.describeRoute({ description: 'Returns the live balance of one TIP-20 token held by an account, including token metadata and a human-readable amount.', operationId: 'getAddressBalance', responses: OpenApi.responses({ errors: { 400: { codes: [ 'address_invalid', 'chain_id_invalid', 'chain_id_unsupported', 'query_invalid', ], }, 404: { description: 'The token is not a registered TIP-20 token.', codes: ['token_not_found'], }, 502: 'Could not read balance or token data from an upstream service.', }, success: { description: 'The requested token balance for this account.', schema: schema.getAddressBalance.Response, }, }), summary: 'Get address balance', tags: ['Balances'], }), Cache.response({ cacheControl: Cache.policies.noStore, name: 'tempo-api:addresses:v1', key: (c) => Cache.urlKey(c, schema.getAddressBalance.Query), }), async (c) => { if (Auth.narrowAccess) return Auth.paidAccessError(c) if (OpenApi.narrowValidation) return OpenApi.validationError(c, { code: 'address_invalid', message: 'Invalid request parameters', }) if (OpenApi.narrowValidation) return OpenApi.validationError(c, { code: 'query_invalid', message: 'Invalid request parameters', }) const { address, token } = c.req.valid('param') const query = c.req.valid('query') const chainId = query.chainId ?? c.get('chainId') const denomination = query['valuation.currency'] try { const rates = denomination ? await Valuation.rateSet(c, oracle) .then((rates) => { if (denomination !== rates.base && rates.rates[denomination] === undefined) throw new Valuation.UnsupportedDenominationError(denomination, oracle.name) return rates }) .catch((cause) => { if (cause instanceof Valuation.UnsupportedDenominationError) throw cause return undefined }) : undefined const snapshotPromise = VerifiedTokens.snapshot(c, chainId) const resolvedTokenPromise = snapshotPromise.then((snapshot) => Timing.time(c, 'token', () => Tokens.resolveToken(c, { address: token, chainId, include: [], snapshot }), ), ) const client = c.get('getClient')(chainId) const caller = c.get('zones').has(chainId) ? address : undefined const [balance, feeTokens, snapshot, resolvedToken] = await Promise.all([ Timing.time(c, 'balance_rpc', async () => { const call = Actions.token.getBalance.call(client, { account: address, token, }) return client.readContract({ ...call, account: caller }) }), FeeAmm.feeTokenSet(c, chainId).catch(() => undefined), snapshotPromise, resolvedTokenPromise, ]) return c.json( Response.validated(schema.getAddressBalance.Response, { amount: balance.toString(), currency: resolvedToken.currency, decimals: resolvedToken.decimals, ...(feeTokens ? { feeEligible: feeTokens.has(token) } : {}), formatted: core_Value.format(balance, resolvedToken.decimals), id: token, token: resolvedToken, ...(denomination ? { valuation: Valuation.valuationFor({ amount: balance, denomination, rates, token: snapshot.byAddress.get(token), }), } : {}), }), 200, ) } catch (cause) { if (Tokens.isTokenNotFound(cause)) return Response.error(c, { code: 'token_not_found', message: 'Token not found', status: 404, }) if (cause instanceof Valuation.UnsupportedDenominationError) return Response.error(c, { code: 'query_invalid', message: cause.message, status: 400 }) return Response.upstream(c, cause) } }, ) } export declare namespace addresses { /** Options for the address-scoped balance handlers. */ type Options = { /** FX configuration backing per-row valuation. */ fx?: Valuation.addresses.Fx | undefined } } async function getAddressBalances( c: Context, options: getAddressBalances.Options, ) { const { address, chainId, currency, denomination, feeEligible, includeTotalCount, limit, oracle, verified, } = options const tidx = c.get('getTidx')(chainId) const cursor = options.cursor ? Cursor.decode(options.cursor, ['uint', 'address']) : undefined const offset = options.page !== undefined && options.page > 1 ? (options.page - 1) * limit : 0 // Validate an explicit denomination before balance discovery and correction // so dependency failures cannot mask a caller error. if (denomination) await Valuation.ratesFor(c, { currencies: [], denomination, oracle }) // Build the optional `token IN (…)` restriction before hitting TIDX. The // verified/`currency` filter and the `feeEligible` filter each contribute a set // of allowed addresses (`currency` matched case-insensitively so callers can // type `usd`/`USD`/`Usd`); when both are present the page is their // intersection. If any active filter rules every token out, return an empty // page without a round trip. The snapshot is reused for the page enrichment // below. const snapshotPromise = VerifiedTokens.snapshot(c, chainId) // Fee filtering can fail before the concurrently started snapshot is awaited. // Observing this branch prevents an unhandled rejection; the later await still propagates it. void snapshotPromise.catch(() => undefined) const [snapshot_filter, feeTokens_filter] = await Promise.all([ verified ? snapshotPromise : Promise.resolve(undefined), feeEligible ? FeeAmm.feeTokenSet(c, chainId) : Promise.resolve(undefined), ]) const allowed = allowedTokens({ currency, feeEligible, feeTokens: feeTokens_filter, snapshot: snapshot_filter, verified, }) const tokenFilter = allowed?.map((address) => `'${address}'`) if (tokenFilter?.length === 0) { await snapshotPromise return { data: [], ...(includeTotalCount ? { meta: { totalCount: 0, totalCountCapped: false } } : {}), nextCursor: null, } } const holdingsPromise = Timing.time(c, 'balances_discovery', async () => { const tokenIn = tokenFilter ? ` AND token IN (${tokenFilter.join(', ')})` : '' const result = await tidx.fetch({ chainId, engine: 'clickhouse', // Discovery tolerates stale and reorged rows: RPC removes candidates // with no current balance, so avoid the cost of `FINAL` and aggregation. query: ` SELECT DISTINCT token FROM address_holder_deltas WHERE holder = '${address}' AND startsWith(token, '${tip20AddressPrefix}')${tokenIn} ` as string, }) return result.rows.flatMap((row) => { const token = Schema.Address.safeParse(row['token']) return token.success ? [{ address: token.data }] : [] }) }) // Correction starts as soon as discovery completes. Snapshot and fee-token // cache misses continue concurrently until enrichment needs their results. const correctedPromise = holdingsPromise.then(async (holdings) => { if (holdings.length === 0) return [] const client = c.get('getClient')(chainId) const caller = c.get('zones').has(chainId) ? address : undefined return Timing.time(c, 'balances_rpc', async () => { // Concurrent reads enter viem's shared batch window and become one // deployless multicall. Missing contracts are stale indexed candidates; // other read failures still fail the request. const results = await Promise.all( holdings.map(async (holding) => { try { const call = Actions.token.getBalance.call(client, { account: address, token: holding.address, }) const amount = await client.readContract({ ...call, account: caller }) return amount === 0n ? undefined : { address: holding.address, amount: amount.toString() } } catch (cause) { if (!(await client.getCode({ address: holding.address }))) return undefined throw cause } }), ) const rows = results.filter((holding) => holding !== undefined) rows.sort((a, b) => { const delta = BigInt(b.amount) - BigInt(a.amount) if (delta !== 0n) return delta > 0n ? 1 : -1 return a.address < b.address ? -1 : a.address > b.address ? 1 : 0 }) return rows }) }) const feeTokensPromise = feeEligible ? Promise.resolve(feeTokens_filter) : holdingsPromise.then((holdings) => holdings.length === 0 ? undefined : FeeAmm.feeTokenSet(c, chainId).catch(() => undefined), ) const [holdings, snapshot, feeTokens, corrected] = await Promise.all([ holdingsPromise, snapshotPromise, feeTokensPromise, correctedPromise, ]) if (holdings.length === 0) { return { data: [], ...(includeTotalCount ? { meta: { totalCount: 0, totalCountCapped: false } } : {}), nextCursor: null, } } const positioned = (() => { if (!cursor) return corrected.slice(offset) const amount = BigInt(cursor[0]!) const address = String(cursor[1]!) return corrected.filter( (holding) => BigInt(holding.amount) < amount || (BigInt(holding.amount) === amount && holding.address > address), ) })() const page = Cursor.paginate({ rows: positioned, limit, key: (holding) => [holding.amount, holding.address], }) if (page.rows.length === 0) { return { data: [], ...(includeTotalCount ? { meta: { totalCount: corrected.length, totalCountCapped: false } } : {}), nextCursor: page.nextCursor, } } const ratesPromise = denomination ? Valuation.ratesFor(c, { currencies: page.rows.flatMap((row) => { const held = snapshot.byAddress.get(row.address.toLowerCase())?.currency return held === undefined ? [] : [held] }), denomination, oracle, }) : Promise.resolve(undefined) // Balance tokens retain their logo and verification metadata by default. const resolvedPromise = Timing.time(c, 'tokens', () => Promise.all( page.rows.map(async (holding) => { try { const token = Schema.Address.parse(holding.address.toLowerCase()) const verifiedToken = snapshot.byAddress.get(token) // The curated list owns verified-token metadata, but uploaded logos live only in the asset store. if (verifiedToken) return { amount: holding.amount, logoUri: verifiedToken.logoUri ?? (await Timing.time(c, 'token_logo', () => Tokens.getTokenLogo(c, { address: token, chainId }), ).catch(() => undefined)), metadata: verifiedToken, token, } const [logoUri, metadata] = await Promise.all([ Timing.time(c, 'token_logo', () => Tokens.getTokenLogo(c, { address: token, chainId }), ).catch(() => undefined), Timing.time(c, 'token_metadata', () => Tokens.getTokenMetadata(c, { address: token, chainId }), ), ]) return { amount: holding.amount, logoUri, metadata, token } } catch { return undefined } }), ), ) const [rates, resolved] = await Promise.all([ratesPromise, resolvedPromise]) const data: z.output[] = [] for (const entry of resolved) { if (!entry) continue data.push({ amount: entry.amount, currency: entry.metadata.currency, decimals: entry.metadata.decimals, // Best-effort: omit `feeEligible` entirely when the fee-token set was // unavailable, rather than reporting a misleading `false`. ...(feeTokens ? { feeEligible: feeTokens.has(entry.token.toLowerCase()) } : {}), formatted: core_Value.format(BigInt(entry.amount), entry.metadata.decimals), id: entry.token, token: { address: entry.token, currency: entry.metadata.currency, decimals: entry.metadata.decimals, id: entry.token, logoUri: entry.logoUri ?? snapshot.byAddress.get(entry.token)?.logoUri ?? entry.metadata.logoUri, name: entry.metadata.name, symbol: entry.metadata.symbol, verified: snapshot.byAddress.has(entry.token), }, ...(denomination ? { valuation: Valuation.valuationFor({ amount: BigInt(entry.amount), denomination, rates, token: snapshot.byAddress.get(entry.token), }), } : {}), }) } const meta = { ...(rates ? { valuation: Valuation.pricing(rates, oracle) } : {}), ...(includeTotalCount ? { totalCount: corrected.length, totalCountCapped: false } : {}), } return { data, ...(Object.keys(meta).length > 0 ? { meta } : {}), nextCursor: page.nextCursor, } } declare namespace getAddressBalances { type Options = { address: z.output chainId: z.output /** Only include verified tokens denominated in this currency (implies `verified`). */ currency?: string | undefined /** Opaque keyset cursor anchoring the page; omit for the head page. */ cursor?: string | undefined /** Currency the per-row valuations are denominated in (uppercase). */ denomination?: string | undefined /** When true, restrict the page to fee-usable tokens. */ feeEligible?: boolean | undefined /** When true, include the corrected candidate count in response metadata. */ includeTotalCount: boolean limit: number /** FX rate oracle backing per-row valuation. */ oracle: FxOracle.Oracle /** 1-indexed page number (positional pagination; exclusive with `cursor`). */ page?: number | undefined /** When true, restrict the page to addresses in the curated verified list. */ verified?: boolean | undefined } } /** * Computes the optional set of allowed token addresses for the page's * `token IN (…)` restriction, combining the verified/`currency` filter with the * `feeEligible` filter. Each active filter contributes a candidate address list; * when both are active the result is their intersection. Returns `undefined` * when no filter is active (no restriction), or an empty array when an active * filter excludes every token (caller short-circuits to an empty page). * Addresses are lowercased to match the indexer's stored casing. */ function allowedTokens(options: allowedTokens.Options): string[] | undefined { const { currency, feeEligible, feeTokens, snapshot, verified } = options let allowed: string[] | undefined if (verified) { if (!snapshot) throw new Error('Unable to resolve verified token metadata') const matching = currency ? (snapshot.byCurrency.get(currency.toLowerCase()) ?? []) : snapshot.list allowed = matching.map((token) => token.address.toLowerCase()) } // The handler guarantees `feeTokens` is present whenever `feeEligible` filtering // is requested (a lookup failure surfaces as a `502` instead); the `?? []` // is a defensive fall-through that yields an empty page rather than silently // dropping the filter. if (feeEligible) { if (!feeTokens) allowed = [] else if (allowed) allowed = allowed.filter((address) => feeTokens.has(address)) else allowed = Array.from(feeTokens) } return allowed } declare namespace allowedTokens { type Options = { /** Only include verified tokens denominated in this currency (implies `verified`). */ currency?: string | undefined /** When true, restrict to fee-usable tokens (members of `feeTokens`). */ feeEligible?: boolean | undefined /** The chain's fee-token set, if resolved (best-effort). */ feeTokens?: ReadonlySet | undefined /** Compiled verified-token snapshot for the chain. */ snapshot?: VerifiedTokens.Snapshot | undefined /** When true, restrict to addresses in the curated verified list. */ verified?: boolean | undefined } }