import type { Context } from 'hono' import { sql } from 'kysely' import { Hex } from 'ox' import * as z from 'zod/mini' import type * as App from '../App.js' import * as Db from '../db/Db.js' import * as core_VerifiedTokens from '../db/tables/verifiedTokens.js' import * as Schema from './Schema.js' import * as Timing from './Timing.js' /** Chain id covered by the curated verified-token lists. */ export type ChainId = z.output /** Schema for a chain's verified token list (validated at the write boundary). */ export const Data = z .array( z .object({ address: Schema.Address.check(z.describe('Verified TIP-20 token contract address.')), currency: z.string().check(z.describe('Verified token display currency.')), decimals: z .number() .check(z.int(), z.nonnegative(), z.describe('Verified token decimal precision.')), id: z.string().check(z.describe('Stable resource id (the token address).')), logoUri: z .optional(z.url({ protocol: /^https$/ })) .check(z.describe('Curated HTTPS URL for the token logo image, when set.')), name: z.string().check(z.describe('Verified token display name.')), symbol: z.string().check(z.describe('Verified token ticker symbol.')), }) .check(z.describe('Static verified token entry.')), ) .check(z.describe("A chain's verified token list.")) type SnapshotToken = z.output[number] /** A single curated verified TIP-20 token accepted by write/compile APIs. */ export type Token = Omit & { id?: string | undefined } /** Verified-token input accepted before the stable resource id is derived. */ export type TokenInput = Token /** * A compiled, indexed snapshot of one chain's curated verified-token list. * Fetched once per request and threaded into per-row enrichment so lookups are * plain in-memory map reads rather than repeated array scans. */ export type Snapshot = { /** Tokens indexed by lowercased contract address (membership + lookup). */ byAddress: ReadonlyMap /** Tokens grouped by lowercased display currency, preserving list order. */ byCurrency: ReadonlyMap /** Tokens indexed by lowercased ticker symbol. */ bySymbol: ReadonlyMap /** Chain the snapshot belongs to. */ chainId: number /** Sorted, distinct display currencies present in the list. */ currencies: readonly string[] /** The curated tokens in their canonical order. */ list: readonly SnapshotToken[] /** ISO timestamp of the snapshot's last update. */ updatedAt: string /** Opaque snapshot version; changes whenever the list changes. */ version: string } /** Compiles a raw token list into an indexed {@link Snapshot}. */ export function compile(options: compile.Options): Snapshot { const { chainId, tokens, updatedAt, version } = options // Backfill the derived `id` (lowercased address) without re-parsing through // `Data`/`Schema.Address`, which would lowercase the stored `address` and // drop its curated checksum casing. Validation happens at the write boundary // (`normalize`); reads trust the typed columns. const list: SnapshotToken[] = tokens.map((token) => ({ ...token, id: token.id ?? (token.address.toLowerCase() as Hex.Hex), })) const byAddress = new Map() const byCurrency = new Map() const bySymbol = new Map() for (const token of list) { byAddress.set(token.address.toLowerCase(), token) bySymbol.set(token.symbol.toLowerCase(), token) const currency = token.currency.toLowerCase() const group = byCurrency.get(currency) if (group) group.push(token) else byCurrency.set(currency, [token]) } return { byAddress, byCurrency, bySymbol, chainId, currencies: Array.from(new Set(list.map((token) => token.currency))).sort(), list, updatedAt, version, } } export declare namespace compile { /** Options for {@link compile}. */ type Options = { /** Chain the tokens belong to. */ chainId: number /** Curated tokens in canonical order. */ tokens: readonly TokenInput[] /** ISO timestamp of the snapshot's last update. */ updatedAt: string /** Opaque snapshot version. */ version: string } } // Storage layout: one `verified_tokens` row per curated token (ordered by // `position`) plus a per-chain `verified_token_lists` row carrying the opaque // list `version` the soft-refresh cache and `If-Match` preconditions compare. /** A chain's list-level metadata (the "head" the cache compares against). */ export type Head = core_VerifiedTokens.Meta /** Reads a chain's list metadata, or `null` when it has no list yet. */ async function readHead(db: Db.Db, chainId: ChainId): Promise { return (await core_VerifiedTokens.head(db, chainId)) ?? null } /** * Reads and compiles a chain's current verified list. Returns `null` when the * chain has none. */ export async function read(db: Db.Db, chainId: ChainId): Promise { const head = await readHead(db, chainId) if (!head) return null const rows = await core_VerifiedTokens.list(db, chainId) return compile({ chainId, tokens: rows.map(core_VerifiedTokens.toToken), updatedAt: head.updatedAt, version: head.version, }) } // Tracks the last epoch-ms used by `nextVersion` so versions stay strictly // monotonic even when multiple writes land in the same millisecond. let lastVersionMs = 0 // Generates a monotonic, collision-resistant snapshot version. Zero-padded // epoch-ms sorts chronologically by string compare (matching webhook ids) and // is bumped forward when two writes share a millisecond so the prefix alone // strictly increases per process; the random suffix keeps versions opaque. function nextVersion(): string { lastVersionMs = Math.max(Date.now(), lastVersionMs + 1) return `${lastVersionMs.toString().padStart(15, '0')}_${Hex.random(4).slice(2)}` } /** * Validates a whole token list and normalizes it for storage: addresses are * lowercased, and address + case-insensitive symbol must be unique across the * entire list. Throws {@link DuplicateAddressError} / * {@link DuplicateSymbolError} on a collision. */ function normalize(chainId: ChainId, tokens: readonly TokenInput[]): Token[] { const list = Data.parse(withIds(tokens)).map((token) => ({ ...token, address: token.address.toLowerCase() as Hex.Hex, id: token.address.toLowerCase() as Hex.Hex, })) const addresses = new Set() const symbols = new Set() for (const token of list) { if (addresses.has(token.address)) throw new DuplicateAddressError({ address: token.address, chainId }) // prettier-ignore addresses.add(token.address) const symbol = token.symbol.toLowerCase() if (symbols.has(symbol)) throw new DuplicateSymbolError({ chainId, symbol: token.symbol }) symbols.add(symbol) } return list } // Backfills the derived `id` (lowercased address) onto raw token records // without touching the stored `address` casing, so compiled snapshots preserve // the curated checksum form while still exposing a stable lowercase id. function withIds(tokens: readonly unknown[]): unknown[] { return tokens.map((token) => { if (!token || typeof token !== 'object' || !('address' in token)) return token return { ...(token as Record), id: String(token.address).toLowerCase() } }) } // Returns the current authoritative list for a chain to read-modify-write // against: the durable snapshot, or an empty list when the chain has no head yet // (the first write starts the chain's list from scratch). async function currentList(db: Db.Db, chainId: ChainId): Promise { return (await read(db, chainId))?.list ?? [] } // Optimistic concurrency: when `ifMatch` is set, the caller's expected version // must equal the current head version. Runs inside the publish lock, so — // unlike the old best-effort KV check — the compare-and-set actually holds. async function assertVersion( db: Db.Db, chainId: ChainId, ifMatch: string | undefined, ): Promise { if (ifMatch === undefined) return const head = await readHead(db, chainId) const actual = head?.version ?? emptyVersion if (actual !== ifMatch) throw new VersionMismatchError({ actual, chainId, expected: ifMatch }) } /** * Fixed first key for the per-chain publish advisory lock (the ASCII bytes of * `"vtok"`); the chain id is the second key. */ const publishLockKey = 0x76746f6b // Runs a write's read-modify-publish inside one transaction holding a // per-chain advisory lock, serializing concurrent writers so `assertVersion` // and the read-modify-write can't interleave (the lock releases on commit). async function withPublishLock( db: Db.Db, chainId: ChainId, fn: (tx: Db.Db) => Promise, ): Promise { return db.transaction(async (tx) => { await sql`SELECT pg_advisory_xact_lock(${publishLockKey}, ${chainId})`.execute(tx.kysely) return fn(tx) }) } // Validate-replace-prime: validate the whole list, replace the chain's rows // and bump its list version (atomic inside the publish lock's transaction), // then prime this isolate for read-your-write. async function publish( db: Db.Db, chainId: ChainId, tokens: readonly TokenInput[], ): Promise { const list = normalize(chainId, tokens) const version = nextVersion() const updatedAt = new Date().toISOString() await core_VerifiedTokens.publish(db, chainId, { tokens: list, updatedAt, version }) const snapshot = compile({ chainId, tokens: list, updatedAt, version }) prime(chainId, snapshot) return snapshot } /** * Appends a verified token to a chain's list and publishes a new snapshot. * Throws on a duplicate address or (case-insensitive) symbol. */ export async function create(db: Db.Db, input: create.Input): Promise { const { chainId, currency, decimals, logoUri, name, symbol } = input const address = input.address.toLowerCase() as Hex.Hex return withPublishLock(db, chainId, async (tx) => { const list = await currentList(tx, chainId) const token: Token = { address, currency, decimals, id: address, name, symbol, ...(logoUri === undefined ? {} : { logoUri }), } const snapshot = await publish(tx, chainId, [...list, token]) return { snapshot, token: snapshot.byAddress.get(address)! } }) } export declare namespace create { /** Input for {@link create}. */ type Input = { /** TIP-20 token contract address (normalized to lowercase). */ address: string /** Chain the token belongs to. */ chainId: ChainId /** Display currency, e.g. `USD`. */ currency: string /** Decimal precision. */ decimals: number /** Curated HTTPS logo URL. Omit when no curated logo is set. */ logoUri?: string | undefined /** Display name. */ name: string /** Ticker symbol. */ symbol: string } /** Result of {@link create}. */ type Result = { /** The published snapshot reflecting the new token. */ snapshot: Snapshot /** The created token. */ token: Token } } /** * Partially updates a verified token in place (preserving list order) and * publishes a new snapshot. Throws {@link NotFoundError} when the address is * absent, and the uniqueness errors when an edit collides with another entry. */ export async function patch( db: Db.Db, chainId: ChainId, address: string, input: patch.Input, options: patch.Options = {}, ): Promise { const target = address.toLowerCase() as Hex.Hex return withPublishLock(db, chainId, async (tx) => { await assertVersion(tx, chainId, options.ifMatch) const list = await currentList(tx, chainId) const index = list.findIndex((token) => token.address.toLowerCase() === target) if (index === -1) throw new NotFoundError({ address: target, chainId }) // Edit in place, overriding only the fields the patch supplies. const next = list.map((token, i) => { if (i !== index) return token const logoUri = input.logoUri ?? token.logoUri return { address: target, currency: input.currency ?? token.currency, decimals: input.decimals ?? token.decimals, id: target, name: input.name ?? token.name, symbol: input.symbol ?? token.symbol, ...(logoUri === undefined ? {} : { logoUri }), } }) const snapshot = await publish(tx, chainId, next) return { snapshot, token: snapshot.byAddress.get(target)! } }) } export declare namespace patch { /** Patchable fields for {@link patch} (address and chain are immutable). */ type Input = { /** Display currency, e.g. `USD`. */ currency?: string | undefined /** Decimal precision. */ decimals?: number | undefined /** Curated HTTPS logo URL. Omitting keeps the current value (use `replace` to clear). */ logoUri?: string | undefined /** Display name. */ name?: string | undefined /** Ticker symbol. */ symbol?: string | undefined } /** Options for {@link patch}. */ type Options = { /** Expected current version for optimistic concurrency (best-effort). */ ifMatch?: string | undefined } } /** * Removes a verified token from a chain's list and publishes a new snapshot. * Throws {@link NotFoundError} when the address is absent. */ export async function remove( db: Db.Db, chainId: ChainId, address: string, options: patch.Options = {}, ): Promise { const target = address.toLowerCase() return withPublishLock(db, chainId, async (tx) => { await assertVersion(tx, chainId, options.ifMatch) const list = await currentList(tx, chainId) const next = list.filter((token) => token.address.toLowerCase() !== target) if (next.length === list.length) throw new NotFoundError({ address: target, chainId }) return { snapshot: await publish(tx, chainId, next) } }) } export declare namespace remove { /** Result of {@link remove}. */ type Result = { /** The published snapshot reflecting the removal. */ snapshot: Snapshot } } /** * Replaces a chain's entire verified list and publishes a new snapshot. Used to * bulk-load the initial list and to roll back to a previous one. */ export async function replace( db: Db.Db, chainId: ChainId, tokens: readonly TokenInput[], options: patch.Options = {}, ): Promise { return withPublishLock(db, chainId, async (tx) => { await assertVersion(tx, chainId, options.ifMatch) return { snapshot: await publish(tx, chainId, tokens) } }) } // Sentinel version/time for the empty snapshot served before a chain has any // durable head (KV is the single source of truth; nothing is verified until it // is written through the API). A real publish replaces these with a monotonic // version + actual `updatedAt`. const emptyVersion = 'empty' const emptyUpdatedAt = new Date(0).toISOString() const emptySnapshots = new Map() /** Returns the memoized empty {@link Snapshot} for a chain (no verified tokens). */ function emptySnapshot(chainId: ChainId): Snapshot { const existing = emptySnapshots.get(chainId) if (existing) return existing const snapshot = compile({ chainId, tokens: [], updatedAt: emptyUpdatedAt, version: emptyVersion, }) emptySnapshots.set(chainId, snapshot) return snapshot } // Default per-isolate soft-refresh window: lookups within this window of the // last head check do zero store I/O. const defaultRefreshMs = 10_000 // Per-isolate compiled snapshot + the wall-clock time it was last verified // against the store's head. type Cell = { /** When the snapshot was last checked against the store head. */ checkedAt: number /** The compiled snapshot served to readers. */ snapshot: Snapshot } const cells = new Map() // Coalesce concurrent refreshes per chain within one isolate so a soft-stale // burst triggers a single head check, not one per in-flight request. const refreshes = new Map>() /** * Primes this isolate's in-memory snapshot for a chain. The writer isolate calls * this after a successful publish so the next read reflects the write without * waiting on store propagation (read-your-write). */ export function prime(chainId: ChainId, snapshot: Snapshot): void { cells.set(chainId, { checkedAt: Date.now(), snapshot }) } // Single-flight wrapper around `refreshCell`: concurrent stale/cold reads for a // chain share one in-flight refresh. function refresh( c: Context, db: Db.Db, chainId: ChainId, cell: Cell | undefined, ): Promise { const existing = refreshes.get(chainId) if (existing) return existing const flight = (async () => { try { return await refreshCell(c, db, chainId, cell) } finally { refreshes.delete(chainId) } })() refreshes.set(chainId, flight) return flight } // Refresh state machine. Soft-stale reads only the head and recompiles // solely on a version change; cold loads the full snapshot (an empty snapshot // when the chain has no head yet). async function refreshCell( c: Context, db: Db.Db, chainId: ChainId, cell: Cell | undefined, ): Promise { // Cold: no prior snapshot. Load from the database, or an empty snapshot when // the chain has no head. A hard failure here propagates (we have nothing good // to serve, and must not resurrect deleted tokens). if (!cell) { const loaded = await Timing.time(c, 'verified_snapshot', () => read(db, chainId)) const snapshot = loaded ?? emptySnapshot(chainId) cells.set(chainId, { checkedAt: Date.now(), snapshot }) return snapshot } // Soft-stale: serve the last good in-memory snapshot and log loudly on // failure rather than dropping to empty (no resurrection of deleted tokens). try { const head = await Timing.time(c, 'verified_head', () => readHead(db, chainId)) // Unchanged version (or head not yet present): keep snapshot, bump checkedAt. if (!head || head.version === cell.snapshot.version) { cells.set(chainId, { checkedAt: Date.now(), snapshot: cell.snapshot }) return cell.snapshot } // Version changed: reload the current list (kept on a racing delete). const snapshot = (await Timing.time(c, 'verified_snapshot', () => read(db, chainId))) ?? cell.snapshot cells.set(chainId, { checkedAt: Date.now(), snapshot }) return snapshot } catch (error) { console.error('[verified-tokens] refresh failed; serving last good snapshot', error) cells.set(chainId, { checkedAt: Date.now(), snapshot: cell.snapshot }) return cell.snapshot } } /** * Returns the compiled verified-token {@link Snapshot} for a chain. * * When the verified-tokens feature is unconfigured, serves an empty snapshot. * When configured, serves a per-isolate snapshot backed by the app database, * refreshed on a soft TTL: fresh lookups do zero database I/O; soft-stale * lookups return the current snapshot immediately and refresh in the background * (single-flight, head-only unless the version changed); a cold isolate loads * from the database (an empty snapshot when no head exists). */ export async function snapshot(c: Context, chainId: ChainId): Promise { return Timing.time(c, 'verified_tokens', () => { const config = c.get('verifiedTokens') // Unconfigured: nothing is verified. No reads, no cell. if (!config) return emptySnapshot(chainId) const refreshMs = config.refreshMs ?? defaultRefreshMs const cell = cells.get(chainId) // Fresh: serve the in-memory snapshot with zero database I/O. if (cell && Date.now() - cell.checkedAt < refreshMs) return cell.snapshot // Soft-stale: serve immediately, refresh in the background. Never blocks. if (cell) { const inflight = refresh(c, Db.get(c.get('dbCached')), chainId, cell) // Workers cancel post-response I/O unless kept alive via `waitUntil`; // without it the refresh dies mid-query and publishes never propagate. // Node has no execution context, and fire-and-forget survives there. try { c.executionCtx.waitUntil(inflight) } catch { void inflight } return cell.snapshot } // Cold: must load before the first response. return refresh(c, Db.get(c.get('dbCached')), chainId, undefined) }) } /** * Well-known display currencies surfaced as an OpenAPI `examples` hint on * filters that accept any currency string but want to populate the well-known * ones in Swagger/Stoplight/Scalar dropdowns. Static hint only — the * authoritative set is `Snapshot.currencies` from the live store. */ export const currencies = ['EUR', 'USD'] /** Thrown when a write would introduce a duplicate address on a chain. */ export class DuplicateAddressError extends Error { override name = 'VerifiedTokens.DuplicateAddressError' constructor(options: { address: string; chainId: number }) { super(`verified token ${options.address} already exists on chain ${options.chainId}.`) } } /** * Thrown when a write would introduce a duplicate (case-insensitive) symbol on a * chain — which would make `GET /tokens/:symbol` ambiguous. */ export class DuplicateSymbolError extends Error { override name = 'VerifiedTokens.DuplicateSymbolError' constructor(options: { chainId: number; symbol: string }) { super(`verified symbol "${options.symbol}" already exists on chain ${options.chainId}.`) } } /** Thrown when a patch/delete targets an address not in a chain's verified list. */ export class NotFoundError extends Error { override name = 'VerifiedTokens.NotFoundError' constructor(options: { address: string; chainId: number }) { super(`verified token ${options.address} not found on chain ${options.chainId}.`) } } /** * Thrown when an `If-Match` precondition does not equal the current head version * (best-effort optimistic concurrency). */ export class VersionMismatchError extends Error { override name = 'VerifiedTokens.VersionMismatchError' constructor(options: { actual: string; chainId: number; expected: string }) { super( `verified-token version mismatch on chain ${options.chainId}: expected "${options.expected}", found "${options.actual}".`, ) } }