diff --git a/apps/address-enrichment/.env.example b/apps/address-enrichment/.env.example new file mode 100644 index 000000000..dd08730b4 --- /dev/null +++ b/apps/address-enrichment/.env.example @@ -0,0 +1,18 @@ +# Database +DATABASE_URL=postgresql://user:password@localhost:5432/address_enrichment + +# Arkham Intel API +ARKHAM_API_KEY=your_arkham_api_key +ARKHAM_API_URL=https://api.arkhamintelligence.com + +# Ethereum RPC (for EOA/contract detection fallback) +RPC_URL=https://eth-mainnet.g.alchemy.com/v2/your_key + +# Anticapture API (for sync command) +ANTICAPTURE_API_URL=http://localhost:4000/graphql +DAO_ID=ENS + +# ENS cache TTL in minutes (default: 60) +# ENS_CACHE_TTL_MINUTES=60 + + diff --git a/apps/address-enrichment/.eslintrc.js b/apps/address-enrichment/.eslintrc.js new file mode 100644 index 000000000..5078c472c --- /dev/null +++ b/apps/address-enrichment/.eslintrc.js @@ -0,0 +1,28 @@ +module.exports = { + root: true, + parser: "@typescript-eslint/parser", + parserOptions: { + project: "./tsconfig.json", + tsconfigRootDir: __dirname, + }, + plugins: ["@typescript-eslint", "prettier"], + extends: [ + "eslint:recommended", + "plugin:@typescript-eslint/recommended", + "plugin:prettier/recommended", + ], + rules: { + "prettier/prettier": "error", + "@typescript-eslint/no-unused-vars": [ + "error", + { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }, + ], + }, + ignorePatterns: [ + "node_modules", + "dist", + "drizzle", + ".eslintrc.js", + "drizzle.config.ts", + ], +}; diff --git a/apps/address-enrichment/README.md b/apps/address-enrichment/README.md new file mode 100644 index 000000000..9b2463e05 --- /dev/null +++ b/apps/address-enrichment/README.md @@ -0,0 +1,243 @@ +# Address Enrichment Service + +A service that enriches Ethereum addresses with labels from Arkham Intel API, ENS data from ethfollow, and determines whether addresses are EOAs or contracts. + +## Features + +- **Arkham Labels**: Fetches entity names and labels from Arkham Intel API +- **ENS Resolution**: Fetches ENS name, avatar, and banner via ethfollow API +- **Address Type Detection**: Determines if an address is an EOA or contract via RPC +- **Hybrid Caching**: Arkham data is stored permanently. ENS data is cached with a configurable TTL and refreshed automatically. +- **OpenAPI Documentation**: Swagger UI available at `/docs` + +## API Endpoints + +### `GET /address/:address` + +Returns enriched data for a single Ethereum address. + +**Response:** + +```json +{ + "address": "0x245445940b317e509002eb682e03f4429184059d", + "isContract": false, + "arkham": { + "entity": "Upbit", + "entityType": "cex", + "label": "Cold Wallet", + "twitter": "Upbit_Global" + }, + "ens": { + "name": "example.eth", + "avatar": "https://euc.li/example.eth", + "banner": "https://i.imgur.com/example.png" + }, + "createdAt": "2024-01-20T10:30:00.000Z" +} +``` + +> `ens` is `null` when the address has no ENS name. + +### `POST /addresses` + +Batch endpoint for resolving multiple addresses at once (max 100 per request). + +**Request:** + +```json +{ + "addresses": [ + "0x245445940b317e509002eb682e03f4429184059d", + "0x1234567890abcdef1234567890abcdef12345678" + ] +} +``` + +**Response:** + +```json +{ + "results": [ + { + "address": "0x245445940b317e509002eb682e03f4429184059d", + "isContract": false, + "arkham": { + "entity": "Upbit", + "entityType": "cex", + "label": "Cold Wallet", + "twitter": "Upbit_Global" + }, + "ens": { + "name": "example.eth", + "avatar": "https://euc.li/example.eth", + "banner": "https://i.imgur.com/example.png" + }, + "createdAt": "2024-01-20T10:30:00.000Z" + } + ], + "errors": [ + { + "address": "0x1234567890abcdef1234567890abcdef12345678", + "error": "Failed to fetch from Arkham API" + } + ] +} +``` + +### `GET /health` + +Health check endpoint. + +## Environment Variables + +| Variable | Description | Required | +| ----------------------- | ---------------------------------- | -------------------------------------------------- | +| `DATABASE_URL` | PostgreSQL connection string | Yes | +| `ARKHAM_API_KEY` | Arkham Intel API key | Yes | +| `ARKHAM_API_URL` | Arkham API base URL | No (default: `https://api.arkhamintelligence.com`) | +| `RPC_URL` | Ethereum RPC URL | Yes | +| `ANTICAPTURE_API_URL` | Anticapture GraphQL API URL | Yes (for sync command) | +| `ENS_CACHE_TTL_MINUTES` | TTL in minutes for cached ENS data | No (default: `60`) | +| `PORT` | Server port | No (default: `3001`) | + +## Development + +```bash +# From monorepo root +pnpm address-enrichment dev + +# Run database migrations +pnpm address-enrichment db:push + +# Type check +pnpm address-enrichment typecheck + +# Lint +pnpm address-enrichment lint +``` + +## Sync Command + +Batch-sync top addresses from Anticapture API (delegates + token holders): + +```bash +# Sync top 100 delegates and top 100 token holders +pnpm address-enrichment sync --limit 100 + +# Sync only delegates +pnpm address-enrichment sync --limit 50 --delegates-only + +# Sync only token holders +pnpm address-enrichment sync --limit 50 --holders-only + +# Show help +pnpm address-enrichment sync --help +``` + +The sync command: + +- Fetches top addresses from Anticapture API (delegates by voting power, holders by balance) +- Skips addresses already in the database (no re-fetching) +- Only calls Arkham API and RPC for new addresses +- Deduplicates addresses that appear in both lists + +## Database Schema + +The service uses a single table `address_enrichment`: + +- `address` (PK): Ethereum address (42 chars) +- `is_contract`: Boolean indicating if address is a contract +- `arkham_entity`: Entity name from Arkham (e.g., "Upbit", "Binance") +- `arkham_entity_type`: Entity type from Arkham (e.g., "cex", "dex", "defi") +- `arkham_label`: Specific label from Arkham (e.g., "Cold Wallet", "Hot Wallet") +- `arkham_twitter`: Twitter/X handle from Arkham (e.g., "Upbit_Global") +- `ens_name`: ENS name (e.g., "vitalik.eth") +- `ens_avatar`: ENS avatar URL +- `ens_banner`: ENS banner/header URL +- `ens_updated_at`: Timestamp when ENS data was last fetched (used for TTL) +- `created_at`: Timestamp when the record was first created + +> Arkham data is permanent. ENS data is refreshed when `ens_updated_at` is older than `ENS_CACHE_TTL_MINUTES`. + +## Data Flow + +1. Request comes in for `GET /address/0x123...` +2. Check if address exists in database +3. If found: + - Arkham data: return as-is (permanent) + - ENS data: check `ens_updated_at` against TTL + - Fresh: return cached ENS data + - Stale or missing: refetch from ethfollow API, update row +4. If not found: + - Call Arkham API and ethfollow API in parallel + - If Arkham doesn't have contract info, fall back to RPC `getCode` + - Store everything in PostgreSQL + - Return enriched data + +## Sequence Diagram — `GET /address/:address` + +```mermaid +sequenceDiagram + actor Client + participant API as Address Enrichment API + participant DB as PostgreSQL + participant Arkham as Arkham Intel API + participant ENS as ethfollow API + participant RPC as Ethereum RPC + + Client->>API: GET /address/0x123... + API->>API: Validate address format + + alt Invalid address + API-->>Client: 400 Invalid address + end + + %% ── Check local storage ── + API->>DB: Lookup address + DB-->>API: result + + alt Already enriched + alt ENS data fresh + API-->>Client: 200 Enriched data (cached) + else ENS data stale or missing + API->>ENS: GET /api/v1/users/0x123.../ens + ENS-->>API: name, avatar, banner + API->>DB: UPDATE ens columns + API-->>Client: 200 Enriched data (ENS refreshed) + end + end + + %% ── Address not in DB: fetch all in parallel ── + par Fetch in parallel + API->>Arkham: Get address intelligence + Arkham-->>API: labels, entity, contract info + and + API->>ENS: GET /api/v1/users/0x123.../ens + ENS-->>API: name, avatar, banner + end + + %% ── Determine if contract or EOA ── + alt Arkham knows contract type + API->>API: Use Arkham's answer + else Arkham doesn't know + API->>RPC: Check bytecode on-chain + RPC-->>API: bytecode (or empty) + end + + %% ── Store ── + API->>DB: INSERT enriched address + DB-->>API: stored + + API-->>Client: 200 Enriched data +``` + +## Summary + +Architecture overview: + +- Framework: Hono with OpenAPI/Zod validation +- Database: PostgreSQL with Drizzle ORM +- External APIs: Arkham Intel API, ethfollow (ENS), Ethereum RPC, Anticapture GraphQL API +- Features: Single and batch enrichment endpoints, hybrid caching (permanent Arkham + TTL-based ENS), sync script +- Data flow: Check DB → Fetch Arkham + ENS in parallel → Fallback to RPC → Store → Return diff --git a/apps/address-enrichment/drizzle.config.ts b/apps/address-enrichment/drizzle.config.ts new file mode 100644 index 000000000..b6b6c1237 --- /dev/null +++ b/apps/address-enrichment/drizzle.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "drizzle-kit"; + +export default defineConfig({ + schema: "./src/db/schema.ts", + dialect: "postgresql", + dbCredentials: { + url: process.env.DATABASE_URL || "", + }, +}); diff --git a/apps/address-enrichment/package.json b/apps/address-enrichment/package.json new file mode 100644 index 000000000..b99cc51da --- /dev/null +++ b/apps/address-enrichment/package.json @@ -0,0 +1,41 @@ +{ + "name": "@anticapture/address-enrichment", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "tsx watch src/index.ts", + "build": "tsup src/index.ts --format esm --clean", + "start": "node dist/index.mjs", + "sync": "tsx src/scripts/sync-top-addresses.ts", + "lint": "eslint . --ext .ts", + "lint:fix": "eslint . --ext .ts --fix", + "typecheck": "tsc --noEmit", + "clean": "rm -rf node_modules dist *.tsbuildinfo" + }, + "dependencies": { + "@hono/node-server": "^1.13.7", + "@hono/swagger-ui": "^0.5.1", + "@hono/zod-openapi": "^0.19.6", + "dotenv": "^17.2.4", + "drizzle-kit": "^0.31.4", + "drizzle-orm": "~0.41.0", + "hono": "^4.7.10", + "pg": "^8.13.1", + "viem": "^2.37.11", + "zod": "^3.25.3" + }, + "devDependencies": { + "@types/node": "^20.16.5", + "@types/pg": "^8.11.10", + "eslint": "^8.53.0", + "eslint-config-prettier": "^9.1.0", + "eslint-plugin-prettier": "^5.2.1", + "prettier": "^3.5.3", + "tsup": "^8.5.1", + "tsx": "^4.19.4", + "typescript": "^5.8.3" + }, + "engines": { + "node": ">=18.14" + } +} diff --git a/apps/address-enrichment/src/clients/anticapture.ts b/apps/address-enrichment/src/clients/anticapture.ts new file mode 100644 index 000000000..659ab3b94 --- /dev/null +++ b/apps/address-enrichment/src/clients/anticapture.ts @@ -0,0 +1,172 @@ +/** + * GraphQL client for Anticapture API + * Fetches top token hlders and delegates + */ + +export interface DelegateInfo { + accountId: string; + votingPower: string; + delegationsCount: number; +} + +export interface TokenHolderInfo { + address: string; + balance: string; +} + +interface GraphQLResponse { + data?: T; + errors?: Array<{ message: string }>; +} + +export class AnticaptureClient { + private readonly baseUrl: string; + + constructor(baseUrl: string) { + this.baseUrl = baseUrl; + } + + /** + * Fetches top delegates by voting power + */ + async *streamTopDelegates( + daoId: string, + pageSize: number = 100 + ) { + const query = ` + query GetTopDelegates( + $limit: PositiveInt!, + $skip: NonNegativeInt!, + ) { + votingPowers( + orderDirection: desc + limit: $limit + skip: $skip + fromValue: "0" + ) { + items { + accountId + votingPower + delegationsCount + } + } + } + `; + + let skip = 0; + + while (true) { + const response = await this.executeQuery<{ + votingPowers: { items: DelegateInfo[] }; + }>( + query, + { + limit: pageSize, + skip, + }, + daoId + ); + + const items = response.votingPowers.items; + + if (items.length === 0) return; + + for (const item of items) { + yield item; + } + + if (items.length < pageSize) return; + + skip += pageSize; + } + } + + /** + * Streams top token holders by balance using offset pagination + */ + async *streamTopTokenHolders( + daoId: string, + pageSize: number = 100 + ) { + const query = ` + query GetTopTokenHolders( + $limit: PositiveInt!, + $skip: NonNegativeInt!, + ) { + accountBalances( + orderDirection: desc + limit: $limit + skip: $skip + fromValue: "0" + ) { + items { + address + balance + } + } + } + `; + + let skip = 0; + + while (true) { + const response = await this.executeQuery<{ + accountBalances: { items: TokenHolderInfo[] }; + }>( + query, + { + limit: pageSize, + skip, + }, + daoId + ); + + const items = response.accountBalances.items; + + if (items.length === 0) return; + + for (const item of items) { + yield item; + } + + if (items.length < pageSize) return; + + skip += pageSize; + } + } + + private async executeQuery( + query: string, + variables: Record, + daoId: string, + ): Promise { + const response = await fetch(this.baseUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + "anticapture-dao-id": daoId, + }, + body: JSON.stringify({ query, variables }), + }); + + if (!response.ok) { + throw new Error( + `Anticapture API error: ${response.status} ${response.statusText}`, + ); + } + + const result = (await response.json()) as GraphQLResponse; + + if (result.errors?.length) { + throw new Error( + `GraphQL errors: ${result.errors.map((e) => e.message).join(", ")}`, + ); + } + + if (!result.data) { + throw new Error("No data returned from Anticapture API"); + } + + return result.data; + } +} diff --git a/apps/address-enrichment/src/clients/arkham.ts b/apps/address-enrichment/src/clients/arkham.ts new file mode 100644 index 000000000..5f1116479 --- /dev/null +++ b/apps/address-enrichment/src/clients/arkham.ts @@ -0,0 +1,110 @@ +import { z } from "zod"; + +/** + * Arkham Intel API response schema for address intelligence + * Based on https://docs.intel.arkm.com/ + */ +const ArkhamAddressResponseSchema = z.object({ + address: z.string(), + chain: z.string().optional(), + arkhamEntity: z + .object({ + id: z.string().optional(), + name: z.string().optional(), + type: z.string().optional(), // e.g., "cex", "dex", "defi", etc. + note: z.string().optional(), + service: z.string().nullable().optional(), + website: z.string().nullable().optional(), + twitter: z.string().nullable().optional(), + crunchbase: z.string().nullable().optional(), + linkedin: z.string().nullable().optional(), + }) + .optional() + .nullable(), + arkhamLabel: z + .object({ + name: z.string().optional(), + address: z.string().optional(), + chainType: z.string().optional(), + }) + .optional() + .nullable(), + isUserAddress: z.boolean().optional(), + contract: z.boolean().optional(), +}); + +export type ArkhamAddressResponse = z.infer; + +export interface ArkhamData { + entity: string | null; + entityType: string | null; // e.g., "cex", "dex", "defi" + label: string | null; + twitter: string | null; + isContract: boolean | null; // null if Arkham doesn't know +} + +export class ArkhamClient { + private readonly baseUrl: string; + private readonly apiKey: string; + + constructor(baseUrl: string, apiKey: string) { + this.baseUrl = baseUrl; + this.apiKey = apiKey; + } + + /** + * Fetches address intelligence from Arkham API + * @param address - Ethereum address (0x...) + * @returns Label information or null if API error + */ + async getAddressIntelligence(address: string): Promise { + try { + const response = await fetch( + `${this.baseUrl}/intelligence/address/${address}`, + { + method: "GET", + headers: { + "API-Key": this.apiKey, + "Content-Type": "application/json", + }, + }, + ); + + if (!response.ok) { + if (response.status === 404) { + // Address not found in Arkham's database + return { + entity: null, + entityType: null, + label: null, + twitter: null, + isContract: null, + }; + } + console.error( + `Arkham API error: ${response.status} ${response.statusText}`, + ); + return null; + } + + const data = await response.json(); + const parsed = ArkhamAddressResponseSchema.safeParse(data); + + if (!parsed.success) { + console.error("Failed to parse Arkham response:", parsed.error); + return null; + } + + return { + entity: parsed.data.arkhamEntity?.name ?? null, + entityType: parsed.data.arkhamEntity?.type ?? null, + label: parsed.data.arkhamLabel?.name ?? null, + twitter: parsed.data.arkhamEntity?.twitter ?? null, + isContract: parsed.data.contract ?? null, + }; + } catch (error) { + console.error("Failed to fetch from Arkham API:", error); + return null; + } + } +} diff --git a/apps/address-enrichment/src/clients/ens.ts b/apps/address-enrichment/src/clients/ens.ts new file mode 100644 index 000000000..dbb7279ef --- /dev/null +++ b/apps/address-enrichment/src/clients/ens.ts @@ -0,0 +1,83 @@ +import { z } from "zod"; + +/** + * ENS data response schema from ethfollow API + * Based on https://api.ethfollow.xyz/api/v1/users/:addressOrENS/ens + */ +const EnsResponseSchema = z.object({ + ens: z.object({ + name: z.string().nullable().optional(), + address: z.string().nullable().optional(), + avatar: z.string().nullable().optional(), + records: z + .object({ + header: z.string().nullable().optional(), + }) + .passthrough() + .nullable() + .optional(), + }), +}); + +export interface EnsData { + name: string | null; + avatar: string | null; + banner: string | null; +} + +export class ENSClient { + private readonly baseUrl = "https://api.ethfollow.xyz"; + + /** + * Fetches ENS data for an address via ethfollow API + * @param address - Ethereum address (0x...) + * @returns ENS data or null if not found / API error + */ + async getEnsData(address: string): Promise { + try { + const response = await fetch( + `${this.baseUrl}/api/v1/users/${address}/ens`, + { + method: "GET", + headers: { + "Content-Type": "application/json", + }, + }, + ); + + if (!response.ok) { + if (response.status === 404) { + return null; + } + console.error( + `ENS API error: ${response.status} ${response.statusText}`, + ); + return null; + } + + const data = await response.json(); + const parsed = EnsResponseSchema.safeParse(data); + + if (!parsed.success) { + console.error("Failed to parse ENS response:", parsed.error); + return null; + } + + const { ens } = parsed.data; + + // If no name is returned, the address has no ENS + if (!ens.name) { + return null; + } + + return { + name: ens.name ?? null, + avatar: ens.avatar ?? null, + banner: ens.records?.header ?? null, + }; + } catch (error) { + console.error("Failed to fetch ENS data:", error); + return null; + } + } +} diff --git a/apps/address-enrichment/src/controllers/address.ts b/apps/address-enrichment/src/controllers/address.ts new file mode 100644 index 000000000..9dfc7c392 --- /dev/null +++ b/apps/address-enrichment/src/controllers/address.ts @@ -0,0 +1,98 @@ +import { OpenAPIHono as Hono, createRoute, z } from "@hono/zod-openapi"; +import { getAddress } from "viem"; + +import { EnrichmentService } from "@/services/enrichment"; +import { + AddressRequestSchema, + AddressResponseSchema, + AddressesRequestSchema, + AddressesResponseSchema, +} from "./mappers"; + +export function addressController(app: Hono, service: EnrichmentService) { + app.openapi( + createRoute({ + method: "get", + operationId: "getAddress", + path: "/address/{address}", + summary: "Get enriched data for an address", + description: + "Returns label information from Arkham, ENS data, and whether the address is an EOA or contract. Arkham data is stored permanently. ENS data is cached with a configurable TTL.", + tags: ["address"], + request: { + params: AddressRequestSchema, + }, + responses: { + 200: { + description: "Successfully retrieved address enrichment data", + content: { + "application/json": { + schema: AddressResponseSchema, + }, + }, + }, + }, + }), + async (context) => { + const { address } = context.req.valid("param"); + const result = await service.getAddressEnrichment(address); + const response = AddressResponseSchema.safeParse(result); + return context.json(response.data); + }, + ); + + // Batch endpoint + app.openapi( + createRoute({ + method: "get", + operationId: "getAddresses", + path: "/addresses", + summary: "Get enriched data for multiple addresses", + description: + "Returns label information from Arkham, ENS data, and address type for multiple addresses. Maximum 100 addresses per request. Arkham data is stored permanently. ENS data is cached with a configurable TTL.", + tags: ["address"], + request: { + query: AddressesRequestSchema, + }, + responses: { + 200: { + description: "Successfully retrieved batch enrichment data", + content: { + "application/json": { + schema: AddressesResponseSchema, + }, + }, + }, + }, + }), + async (context) => { + const { addresses } = context.req.valid("query"); + + // Deduplicate addresses + const uniqueAddresses = [...new Set(addresses.map((a) => getAddress(a)))]; + + const results: z.infer[] = []; + const errors: { address: string; error: string }[] = []; + + // Process in parallel with concurrency limit + const CONCURRENCY = 10; + for (let i = 0; i < uniqueAddresses.length; i += CONCURRENCY) { + const batch = uniqueAddresses.slice(i, i + CONCURRENCY); + const batchResults = await Promise.allSettled( + batch.map((address) => service.getAddressEnrichment(address)), + ); + + batchResults.forEach((result) => { + if (result.status === "fulfilled") { + const response = AddressResponseSchema.safeParse(result.value); + if (response.success) { + results.push(response.data); + } + } + }); + } + + return context.json({ results, errors }); + }, + ); +} diff --git a/apps/address-enrichment/src/controllers/mappers.ts b/apps/address-enrichment/src/controllers/mappers.ts new file mode 100644 index 000000000..b8d84d875 --- /dev/null +++ b/apps/address-enrichment/src/controllers/mappers.ts @@ -0,0 +1,45 @@ +import { z } from "@hono/zod-openapi"; +import { getAddress, isAddress } from "viem"; + +const AddressSchema = z + .string() + .refine((addr) => isAddress(addr, { strict: false })) + .transform((addr) => addr.toLowerCase()); + +export const AddressRequestSchema = z.object({ + address: AddressSchema, +}); + +export const AddressResponseSchema = z.object({ + address: z.string().transform((addr) => getAddress(addr)), + isContract: z.boolean(), + arkham: z + .object({ + entity: z.string().nullable(), + entityType: z.string().nullable(), + label: z.string().nullable(), + twitter: z.string().nullable(), + }) + .nullable(), + ens: z + .object({ + name: z.string().nullable(), + avatar: z.string().nullable(), + banner: z.string().nullable(), + }) + .nullable(), +}); + +export const AddressesRequestSchema = z.object({ + addresses: z.union([ + AddressSchema.transform((addr) => [addr]), + z + .array(AddressSchema) + .min(1, "At least one address is required") + .max(100, "Maximum 100 addresses per request"), + ]), +}); + +export const AddressesResponseSchema = z.object({ + results: z.array(AddressResponseSchema), +}); diff --git a/apps/address-enrichment/src/db/helpers.ts b/apps/address-enrichment/src/db/helpers.ts new file mode 100644 index 000000000..058c051c3 --- /dev/null +++ b/apps/address-enrichment/src/db/helpers.ts @@ -0,0 +1,46 @@ +import { drizzle } from "drizzle-orm/node-postgres"; +import { Pool } from "pg"; +import { execSync } from "child_process"; +import * as schema from "./schema" + +let db: ReturnType> | null = null; +let pool: Pool | null = null; + +/** + * Initialize the database connection + * @param connectionString - PostgreSQL connection URL + */ +export function initDb(connectionString: string) { + if (db) { + return db; + } + + pool = new Pool({ + connectionString, + }); + + db = drizzle(pool, { schema }); + return db; +} + +/** + * Push schema to the database (like drizzle-kit push) + */ +export function runMigrations(connectionString: string) { + execSync("drizzle-kit push --force", { + env: { ...process.env, DATABASE_URL: connectionString }, + stdio: "inherit", + }); +} + +/** + * Get the database instance + * @throws Error if database is not initialized + */ +export function getDb() { + if (!db) { + throw new Error("Database not initialized. Call initDb() first."); + } + return db; +} + diff --git a/apps/address-enrichment/src/db/index.ts b/apps/address-enrichment/src/db/index.ts new file mode 100644 index 000000000..2bcf78491 --- /dev/null +++ b/apps/address-enrichment/src/db/index.ts @@ -0,0 +1,2 @@ +export * from "./schema"; +export * from "./helpers"; diff --git a/apps/address-enrichment/src/db/schema.ts b/apps/address-enrichment/src/db/schema.ts new file mode 100644 index 000000000..2b075953d --- /dev/null +++ b/apps/address-enrichment/src/db/schema.ts @@ -0,0 +1,29 @@ +import { + pgTable, + varchar, + boolean, + timestamp, + text, +} from "drizzle-orm/pg-core"; + +/** + * Storage for address enrichment data. + * Arkham data is permanent - once fetched, stored forever. + * ENS data is cached with a configurable TTL (ens_updated_at tracks freshness). + */ +export const addressEnrichment = pgTable("address_enrichment", { + address: varchar("address", { length: 42 }).primaryKey(), + isContract: boolean("is_contract").notNull(), + arkhamEntity: varchar("arkham_entity", { length: 255 }), + arkhamEntityType: varchar("arkham_entity_type", { length: 100 }), // e.g., "cex", "dex", "defi" + arkhamLabel: varchar("arkham_label", { length: 255 }), + arkhamTwitter: varchar("arkham_twitter", { length: 255 }), + ensName: varchar("ens_name", { length: 255 }), + ensAvatar: text("ens_avatar"), + ensBanner: text("ens_banner"), + ensUpdatedAt: timestamp("ens_updated_at"), + createdAt: timestamp("created_at").notNull().defaultNow(), +}); + +export type AddressEnrichment = typeof addressEnrichment.$inferSelect; +export type NewAddressEnrichment = typeof addressEnrichment.$inferInsert; diff --git a/apps/address-enrichment/src/env.ts b/apps/address-enrichment/src/env.ts new file mode 100644 index 000000000..d85cb3c34 --- /dev/null +++ b/apps/address-enrichment/src/env.ts @@ -0,0 +1,34 @@ +import { z } from "zod"; +import dotenv from "dotenv"; +import { DaoIdEnum } from "./utils/types"; + +dotenv.config(); + +const envSchema = z.object({ + DATABASE_URL: z.string().min(1, "DATABASE_URL is required"), + ARKHAM_API_KEY: z.string().min(1, "ARKHAM_API_KEY is required"), + ARKHAM_API_URL: z + .string() + .url() + .default("https://api.arkhamintelligence.com"), + RPC_URL: z.string().url("RPC_URL must be a valid URL"), + ANTICAPTURE_API_URL: z.string().url().optional(), + ENS_CACHE_TTL_MINUTES: z.coerce.number().default(60), + PORT: z.coerce.number().default(3001), +}); + +export type Env = z.infer; + +function validateEnv(): Env { + const result = envSchema.safeParse(process.env); + + if (!result.success) { + console.error("❌ Invalid environment variables:"); + console.error(result.error.flatten().fieldErrors); + process.exit(1); + } + + return result.data; +} + +export const env = validateEnv(); diff --git a/apps/address-enrichment/src/index.ts b/apps/address-enrichment/src/index.ts new file mode 100644 index 000000000..30f6a23c1 --- /dev/null +++ b/apps/address-enrichment/src/index.ts @@ -0,0 +1,78 @@ +import { serve } from "@hono/node-server"; +import { OpenAPIHono as Hono } from "@hono/zod-openapi"; +import { swaggerUI } from "@hono/swagger-ui"; +import { logger } from "hono/logger"; +import { cors } from "hono/cors"; + +import { env } from "@/env"; +import { initDb, runMigrations } from "@/db"; +import { ArkhamClient } from "@/clients/arkham"; +import { ENSClient } from "@/clients/ens"; +import { EnrichmentService } from "@/services/enrichment"; +import { addressController } from "@/controllers/address"; + +// Initialize clients and services +const arkhamClient = new ArkhamClient(env.ARKHAM_API_URL, env.ARKHAM_API_KEY); +const ensClient = new ENSClient(); +const enrichmentService = new EnrichmentService( + arkhamClient, + ensClient, + env.RPC_URL, + env.ENS_CACHE_TTL_MINUTES, +); + +// Create Hono app +const app = new Hono(); + +// Middleware +app.use(logger()); +app.use( + cors({ + origin: "*", + }), +); + +app.onError((err, c) => { + console.error("Unhandled error:", err); + return c.json( + { + error: "Internal server error", + message: err.message ?? "Unknown error occurred", + }, + 500, + ); +}); + +// Health check +app.get("/health", (c) => { + return c.json({ status: "ok", timestamp: new Date().toISOString() }); +}); + +// Register controllers +addressController(app, enrichmentService); + +// OpenAPI documentation +app.doc("/docs/json", { + openapi: "3.0.0", + info: { + title: "Address Enrichment API", + version: "0.1.0", + description: + "API for enriching Ethereum addresses with labels and type information", + }, +}); + +app.get("/docs", swaggerUI({ url: "/docs/json" })); + +// Run migrations then start server +initDb(env.DATABASE_URL); +// runMigrations(env.DATABASE_URL); +console.log(`🚀 Address Enrichment API starting on port ${env.PORT}`); +serve({ + fetch: app.fetch, + port: env.PORT, +}); + +console.log( + `📚 API documentation available at http://localhost:${env.PORT}/docs`, +); diff --git a/apps/address-enrichment/src/scripts/sync-top-addresses.ts b/apps/address-enrichment/src/scripts/sync-top-addresses.ts new file mode 100644 index 000000000..671a35276 --- /dev/null +++ b/apps/address-enrichment/src/scripts/sync-top-addresses.ts @@ -0,0 +1,338 @@ +/** + * CLI script to sync top addresses from Anticapture API + * + * Usage: + * pnpm address-enrichment sync --limit 100 + * pnpm address-enrichment sync --limit 50 --delegates-only + * pnpm address-enrichment sync --limit 50 --holders-only + */ + +import { eq } from "drizzle-orm"; +import { getAddress, type Address } from "viem"; + +import { initDb, getDb, addressEnrichment } from "@/db"; +import { ArkhamClient } from "@/clients/arkham"; +import { AnticaptureClient } from "@/clients/anticapture"; +import { isContract, createRpcClient } from "@/utils/address-type"; +import dotenv from "dotenv"; +import { DaoIdEnum } from "@/utils/types"; + +dotenv.config(); + +// Parse environment variables (simplified for CLI) +function getEnv(key: string, defaultValue?: string): string { + const value = process.env[key] ?? defaultValue; + if (!value) { + console.error(`❌ Missing required environment variable: ${key}`); + process.exit(1); + } + return value; +} + +interface SyncOptions { + limit: number; + delegatesOnly: boolean; + holdersOnly: boolean; +} + +function parseArgs(): SyncOptions { + const args = process.argv.slice(2); + const options: SyncOptions = { + limit: 100, + delegatesOnly: false, + holdersOnly: false, + }; + + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (arg === "--limit" && args[i + 1]) { + options.limit = parseInt(args[i + 1]!, 10); + i++; + } else if (arg === "--delegates-only") { + options.delegatesOnly = true; + } else if (arg === "--holders-only") { + options.holdersOnly = true; + } else if (arg === "--help" || arg === "-h") { + console.log(` +Usage: pnpm address-enrichment sync [options] + +Options: + --limit Number of addresses to fetch per category (default: 100) + --delegates-only Only sync top delegates + --holders-only Only sync top token holders + --help, -h Show this help message + +Examples: + pnpm address-enrichment sync --limit 100 + pnpm address-enrichment sync --limit 50 --delegates-only +`); + process.exit(0); + } + } + + return options; +} + +interface EnrichResult { + address: string; + isNew: boolean; + entity: string | null; + entityType: string | null; + label: string | null; + twitter: string | null; + isContract: boolean; +} + +async function enrichAddress( + address: string, + arkhamClient: ArkhamClient, + rpcClient: ReturnType, + db: ReturnType, +): Promise { + const normalizedAddress = address.toLowerCase(); /* FIXME: Unfortunately the addresses have already been commited to + * the database in lowercase format, checksum format could only be + * used here if we were to convert all current records */ + + // Check if already exists + const existing = await db.query.addressEnrichment.findFirst({ + where: eq(addressEnrichment.address, normalizedAddress), + }); + + if (existing) { + return { + address: normalizedAddress, + isNew: false, + entity: existing.arkhamEntity, + entityType: existing.arkhamEntityType, + label: existing.arkhamLabel, + twitter: existing.arkhamTwitter, + isContract: existing.isContract, + }; + } + + // Fetch from Arkham first + const arkhamData = + await arkhamClient.getAddressIntelligence(normalizedAddress); + + // Use Arkham's contract info if available, otherwise fall back to RPC + let isContractAddress: boolean; + if (arkhamData?.isContract !== null && arkhamData?.isContract !== undefined) { + isContractAddress = arkhamData.isContract; + } else { + isContractAddress = await isContract( + rpcClient, + normalizedAddress as Address, + ); + } + + // Store in database + await db + .insert(addressEnrichment) + .values({ + address: normalizedAddress, + isContract: isContractAddress, + arkhamEntity: arkhamData?.entity ?? null, + arkhamEntityType: arkhamData?.entityType ?? null, + arkhamLabel: arkhamData?.label ?? null, + arkhamTwitter: arkhamData?.twitter ?? null, + }) + .onConflictDoNothing(); + + return { + address: normalizedAddress, + isNew: true, + entity: arkhamData?.entity ?? null, + entityType: arkhamData?.entityType ?? null, + label: arkhamData?.label ?? null, + twitter: arkhamData?.twitter ?? null, + isContract: isContractAddress, + }; +} + +/** + * Process addresses as they stream in, enriching immediately + */ +const processAndEnrichDelegates = async ( + anticaptureClient: AnticaptureClient, + arkhamClient: ArkhamClient, + rpcClient: ReturnType, + db: ReturnType, + daoId: string, +): Promise<{ newCount: number; existingCount: number; errorCount: number }> => { + console.log(`\n📊 Fetching and enriching delegates for ${daoId}...`); + + let newCount = 0; + let existingCount = 0; + let errorCount = 0; + let processedCount = 0; + + try { + for await (const d of anticaptureClient.streamTopDelegates(daoId)) { + processedCount++; + const addr = d.accountId; + const progress = `[${daoId} delegate ${processedCount}]`; + const roleStr = `(delegate)`; + + try { + const result = await enrichAddress(addr, arkhamClient, rpcClient, db); + + const arkhamParts: string[] = []; + if (result.entity) arkhamParts.push(result.entity); + if (result.label) arkhamParts.push(`"${result.label}"`); + if (result.entityType) arkhamParts.push(`[${result.entityType}]`); + if (result.twitter) arkhamParts.push(`@${result.twitter}`); + if (result.isContract) arkhamParts.push("📜 contract"); + const arkhamStr = + arkhamParts.length > 0 ? `→ ${arkhamParts.join(" ")}` : "→ unknown"; + + if (result.isNew) { + newCount++; + console.log(` ${progress} ✅ ${addr} ${roleStr} ${arkhamStr}`); + } else { + existingCount++; + console.log(` ${progress} ⏭️ ${addr} ${roleStr} ${arkhamStr}`); + } + } catch (error) { + errorCount++; + console.error( + ` ${progress} ❌ ${addr} ${roleStr}:`, + error instanceof Error ? error.message : error, + ); + } + + // Rate limiting + await new Promise((resolve) => setTimeout(resolve, 100)); + } + } catch (error) { + console.error(` ❌ Failed to fetch delegates for ${daoId}:`, error); + } + + console.log(` Processed ${processedCount} delegates`); + return { newCount, existingCount, errorCount }; +}; + +const processAndEnrichHolders = async ( + anticaptureClient: AnticaptureClient, + arkhamClient: ArkhamClient, + rpcClient: ReturnType, + db: ReturnType, + daoId: string, +): Promise<{ newCount: number; existingCount: number; errorCount: number }> => { + console.log(`\n💰 Fetching and enriching token holders for ${daoId}...`); + + let newCount = 0; + let existingCount = 0; + let errorCount = 0; + let processedCount = 0; + + try { + for await (const h of anticaptureClient.streamTopTokenHolders(daoId)) { + processedCount++; + const addr = h.address; + const progress = `[${daoId} holder ${processedCount}]`; + const roleStr = `(holder)`; + + try { + const result = await enrichAddress(addr, arkhamClient, rpcClient, db); + + const arkhamParts: string[] = []; + if (result.entity) arkhamParts.push(result.entity); + if (result.label) arkhamParts.push(`"${result.label}"`); + if (result.entityType) arkhamParts.push(`[${result.entityType}]`); + if (result.twitter) arkhamParts.push(`@${result.twitter}`); + if (result.isContract) arkhamParts.push("📜 contract"); + const arkhamStr = + arkhamParts.length > 0 ? `→ ${arkhamParts.join(" ")}` : "→ unknown"; + + if (result.isNew) { + newCount++; + console.log(` ${progress} ✅ ${addr} ${roleStr} ${arkhamStr}`); + } else { + existingCount++; + console.log(` ${progress} ⏭️ ${addr} ${roleStr} ${arkhamStr}`); + } + } catch (error) { + errorCount++; + console.error( + ` ${progress} ❌ ${addr} ${roleStr}:`, + error instanceof Error ? error.message : error, + ); + } + + // Rate limiting + await new Promise((resolve) => setTimeout(resolve, 100)); + } + } catch (error) { + console.error(` ❌ Failed to fetch holders for ${daoId}:`, error); + } + + console.log(` Processed ${processedCount} holders`); + return { newCount, existingCount, errorCount }; +}; + +async function main() { + const options = parseArgs(); + + console.log("🚀 Starting address sync..."); + + // Initialize connections + const databaseUrl = getEnv("DATABASE_URL"); + const arkhamApiKey = getEnv("ARKHAM_API_KEY"); + const arkhamApiUrl = getEnv( + "ARKHAM_API_URL", + "https://api.arkhamintelligence.com", + ); + const rpcUrl = getEnv("RPC_URL"); + const anticaptureApiUrl = getEnv("ANTICAPTURE_API_URL"); + + initDb(databaseUrl); + const db = getDb(); + + const arkhamClient = new ArkhamClient(arkhamApiUrl, arkhamApiKey); + const anticaptureClient = new AnticaptureClient(anticaptureApiUrl); + const rpcClient = createRpcClient(rpcUrl); + + let totalNew = 0; + let totalExisting = 0; + let totalErrors = 0; + + for (const daoId of Object.values(DaoIdEnum)) { + if (!options.holdersOnly) { + const results = await processAndEnrichDelegates( + anticaptureClient, + arkhamClient, + rpcClient, + db, + daoId, + ); + totalNew += results.newCount; + totalExisting += results.existingCount; + totalErrors += results.errorCount; + } + + if (!options.delegatesOnly) { + const results = await processAndEnrichHolders( + anticaptureClient, + arkhamClient, + rpcClient, + db, + daoId, + ); + totalNew += results.newCount; + totalExisting += results.existingCount; + totalErrors += results.errorCount; + } + } + + console.log(`\n✨ Sync complete!`); + console.log(` New addresses: ${totalNew}`); + console.log(` Already existed: ${totalExisting}`); + console.log(` Errors: ${totalErrors}`); + + process.exit(0); +} + +main().catch((error) => { + console.error("Fatal error:", error); + process.exit(1); +}); diff --git a/apps/address-enrichment/src/services/enrichment.ts b/apps/address-enrichment/src/services/enrichment.ts new file mode 100644 index 000000000..7da41c307 --- /dev/null +++ b/apps/address-enrichment/src/services/enrichment.ts @@ -0,0 +1,203 @@ +import { eq } from "drizzle-orm"; +import { getAddress, type Address } from "viem"; + +import { getDb, addressEnrichment } from "@/db"; +import type { AddressEnrichment } from "@/db/schema"; +import { ArkhamClient } from "@/clients/arkham"; +import { ENSClient } from "@/clients/ens"; +import { isContract, createRpcClient } from "@/utils/address-type"; +import z from "zod"; + +export const EnrichmentResultSchema = z.object({ + address: z.string(), + isContract: z.boolean(), + arkham: z + .object({ + entity: z.string().nullable(), + entityType: z.string().nullable(), + label: z.string().nullable(), + twitter: z.string().nullable(), + }) + .nullable(), + ens: z + .object({ + name: z.string().nullable(), + avatar: z.string().nullable(), + banner: z.string().nullable(), + }) + .nullable(), + createdAt: z.string(), +}); + +export type EnrichmentResult = z.infer; + +export class EnrichmentService { + private arkhamClient: ArkhamClient; + private ensClient: ENSClient; + private rpcClient: ReturnType; + private ensCacheTtlMinutes: number; + + constructor( + arkhamClient: ArkhamClient, + ensClient: ENSClient, + rpcUrl: string, + ensCacheTtlMinutes: number, + ) { + this.arkhamClient = arkhamClient; + this.ensClient = ensClient; + this.rpcClient = createRpcClient(rpcUrl); + this.ensCacheTtlMinutes = ensCacheTtlMinutes; + } + + /** + * Get enriched data for an address. + * - Arkham data is permanent (fetched once, stored forever). + * - ENS data is cached with a configurable TTL and refetched when stale. + */ + async getAddressEnrichment(address: string): Promise { + const normalizedAddress = + address.toLowerCase(); /* FIXME: Unfortunately the addresses have already been commited to + * the database in lowercase format, checksum format could only be + * used here if we were to convert all current records */ + const db = getDb(); + + // Check if address exists in database + const existing = await db.query.addressEnrichment.findFirst({ + where: eq(addressEnrichment.address, normalizedAddress), + }); + + if (existing) { + // Arkham data is permanent, but ENS may need refreshing + if (this.isEnsFresh(existing)) { + return this.mapToResult(existing); + } + + // ENS data is stale or missing — refetch + const ensData = await this.ensClient.getEnsData(normalizedAddress); + const now = new Date(); + + await db + .update(addressEnrichment) + .set({ + ensName: ensData?.name ?? null, + ensAvatar: ensData?.avatar ?? null, + ensBanner: ensData?.banner ?? null, + ensUpdatedAt: now, + }) + .where(eq(addressEnrichment.address, normalizedAddress)); + + return this.mapToResult({ + ...existing, + ensName: ensData?.name ?? null, + ensAvatar: ensData?.avatar ?? null, + ensBanner: ensData?.banner ?? null, + ensUpdatedAt: now, + }); + } + + // Address not in DB — fetch Arkham + ENS in parallel + const [arkhamData, ensData] = await Promise.all([ + this.arkhamClient.getAddressIntelligence(normalizedAddress), + this.ensClient.getEnsData(normalizedAddress), + ]); + + // Use Arkham's contract info if available, otherwise fall back to RPC + let isContractAddress: boolean; + if ( + arkhamData?.isContract !== null && + arkhamData?.isContract !== undefined + ) { + isContractAddress = arkhamData.isContract; + } else { + isContractAddress = await isContract( + this.rpcClient, + getAddress(normalizedAddress), + ); + } + + const now = new Date(); + + // Store in database + const newRecord: typeof addressEnrichment.$inferInsert = { + address: normalizedAddress, + isContract: isContractAddress, + arkhamEntity: arkhamData?.entity ?? null, + arkhamEntityType: arkhamData?.entityType ?? null, + arkhamLabel: arkhamData?.label ?? null, + arkhamTwitter: arkhamData?.twitter ?? null, + ensName: ensData?.name ?? null, + ensAvatar: ensData?.avatar ?? null, + ensBanner: ensData?.banner ?? null, + ensUpdatedAt: now, + }; + + const [inserted] = await db + .insert(addressEnrichment) + .values(newRecord) + .onConflictDoNothing() + .returning(); + + // If insert failed due to race condition, fetch existing + if (!inserted) { + const existingAfterRace = await db.query.addressEnrichment.findFirst({ + where: eq(addressEnrichment.address, normalizedAddress), + }); + if (existingAfterRace) { + return this.mapToResult(existingAfterRace); + } + // This shouldn't happen, but return fetched data anyway + return { + address: normalizedAddress, + isContract: isContractAddress, + arkham: arkhamData + ? { + entity: arkhamData.entity, + entityType: arkhamData.entityType, + label: arkhamData.label, + twitter: arkhamData.twitter, + } + : null, + ens: ensData, + createdAt: new Date().toISOString(), + }; + } + + return this.mapToResult(inserted); + } + + /** + * Check if ENS data is still fresh based on the configured TTL. + */ + private isEnsFresh(record: AddressEnrichment): boolean { + if (!record.ensUpdatedAt) { + return false; + } + + const ttlMs = this.ensCacheTtlMinutes * 60 * 1000; + const age = Date.now() - record.ensUpdatedAt.getTime(); + return age < ttlMs; + } + + private mapToResult(record: AddressEnrichment): EnrichmentResult { + const hasEnsData = record.ensName !== null; + + return EnrichmentResultSchema.parse({ + address: record.address, + isContract: record.isContract, + arkham: { + entity: record.arkhamEntity, + entityType: record.arkhamEntityType, + label: record.arkhamLabel, + twitter: record.arkhamTwitter, + }, + ens: hasEnsData + ? { + name: record.ensName, + avatar: record.ensAvatar, + banner: record.ensBanner, + } + : null, + createdAt: record.createdAt.toISOString(), + }); + } +} diff --git a/apps/address-enrichment/src/utils/address-type.ts b/apps/address-enrichment/src/utils/address-type.ts new file mode 100644 index 000000000..bdfb8aadb --- /dev/null +++ b/apps/address-enrichment/src/utils/address-type.ts @@ -0,0 +1,36 @@ +import { createPublicClient, http, type Address } from "viem"; +import { mainnet } from "viem/chains"; + +/** + * Creates a viem public client for checking address types + */ +export function createRpcClient(rpcUrl: string) { + return createPublicClient({ + chain: mainnet, + transport: http(rpcUrl), + }); +} + +/** + * Checks if an address is a contract or EOA (Externally Owned Account) + * + * @param client - viem PublicClient + * @param address - Ethereum address to check + * @returns true if contract, false if EOA + * + * Logic: Contracts have bytecode, EOAs have empty bytecode (0x) + */ +export async function isContract( + client: ReturnType, + address: Address, +): Promise { + try { + const bytecode = await client.getCode({ address }); + // If bytecode exists and is not empty (0x), it's a contract + return bytecode !== undefined && bytecode !== "0x"; + } catch (error) { + console.error(`Failed to check address type for ${address}:`, error); + // Default to false (EOA) on error to be conservative + return false; + } +} diff --git a/apps/address-enrichment/src/utils/types.ts b/apps/address-enrichment/src/utils/types.ts new file mode 100644 index 000000000..ec496df1c --- /dev/null +++ b/apps/address-enrichment/src/utils/types.ts @@ -0,0 +1,13 @@ +export enum DaoIdEnum { + // UNI = "UNI", + // ENS = "ENS", + // ARB = "ARB", + OP = "OP", + // GTC = "GTC", + // NOUNS = "NOUNS", + // TEST = "TEST", + // SCR = "SCR", + // COMP = "COMP", + // OBOL = "OBOL", + // ZK = "ZK", +} diff --git a/apps/address-enrichment/tsconfig.json b/apps/address-enrichment/tsconfig.json new file mode 100644 index 000000000..8388f6183 --- /dev/null +++ b/apps/address-enrichment/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "strict": true, + "noUncheckedIndexedAccess": true, + "verbatimModuleSyntax": false, + "esModuleInterop": true, + "isolatedModules": true, + "allowSyntheticDefaultImports": true, + "resolveJsonModule": true, + "moduleResolution": "bundler", + "module": "ESNext", + "noEmit": false, + "outDir": "dist", + "lib": ["ES2022"], + "target": "ES2022", + "skipLibCheck": true, + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["./src/**/*.ts"], + "exclude": ["node_modules", "dist", "drizzle"] +} diff --git a/apps/api-gateway/.env.example b/apps/api-gateway/.env.example index 0298802a9..9c8e817b9 100644 --- a/apps/api-gateway/.env.example +++ b/apps/api-gateway/.env.example @@ -1,2 +1,3 @@ PETITION_API_URL=http://localhost:5000/docs/json DAO_API_ENS=https://localhost:3000 +ADDRESS_ENRICHMENT_API_URL=http://localhost:3001 diff --git a/apps/api-gateway/README.md b/apps/api-gateway/README.md index af0a89fde..d33384dc0 100644 --- a/apps/api-gateway/README.md +++ b/apps/api-gateway/README.md @@ -16,6 +16,7 @@ The gateway is configured through environment variables: - `DAO_API_*`: - URLs for DAO-specific APIs (e.g., `DAO_API_OP=https://api.optimism.dao`) - This is based on the `indexer` package which exposes both a graphql API at the root level, and a rest API on the `/docs` path +- `ADDRESS_ENRICHMENT_API_URL`: URL for the Address Enrichment service (Arkham integration) - `PETITION_API_URL`: URL for the Petition REST API service ## Architecture diff --git a/apps/api-gateway/meshrc.ts b/apps/api-gateway/meshrc.ts index 2c478e920..3448bc7bb 100644 --- a/apps/api-gateway/meshrc.ts +++ b/apps/api-gateway/meshrc.ts @@ -12,6 +12,21 @@ export default processConfig( }, }, sources: [ + // Address Enrichment Service (Arkham integration) + ...(process.env.ADDRESS_ENRICHMENT_API_URL + ? [ + { + name: "address_enrichment", + handler: { + openapi: { + source: `${process.env.ADDRESS_ENRICHMENT_API_URL}/docs/json`, + endpoint: process.env.ADDRESS_ENRICHMENT_API_URL, + }, + }, + }, + ] + : []), + // DAO-specific APIs ...Object.entries(process.env) .filter(([key]) => key.startsWith("DAO_API_")) .flatMap(([key, value]) => { diff --git a/apps/api-gateway/schema.graphql b/apps/api-gateway/schema.graphql index 6700b7163..1d173d3d1 100644 --- a/apps/api-gateway/schema.graphql +++ b/apps/api-gateway/schema.graphql @@ -1,16 +1,26 @@ -directive @enum(subgraph: String, value: String) on ENUM_VALUE - directive @resolveRoot(subgraph: String) on FIELD_DEFINITION +directive @httpOperation(subgraph: String, path: String, operationSpecificHeaders: [[String]], httpMethod: HTTPMethod, isBinary: Boolean, requestBaseBody: ObjMap, queryParamArgMap: ObjMap, queryStringOptionsByParam: ObjMap, jsonApiFields: Boolean, queryStringOptions: ObjMap) on FIELD_DEFINITION + +directive @transport(subgraph: String, kind: String, location: String, headers: [[String]], queryStringOptions: ObjMap, queryParams: [[String]]) repeatable on SCHEMA + +directive @enum(subgraph: String, value: String) on ENUM_VALUE + directive @typescript(subgraph: String, type: String) on SCALAR | ENUM directive @example(subgraph: String, value: ObjMap) repeatable on FIELD_DEFINITION | OBJECT | INPUT_OBJECT | ENUM | SCALAR -directive @httpOperation(subgraph: String, path: String, operationSpecificHeaders: [[String]], httpMethod: HTTPMethod, isBinary: Boolean, requestBaseBody: ObjMap, queryParamArgMap: ObjMap, queryStringOptionsByParam: ObjMap, jsonApiFields: Boolean, queryStringOptions: ObjMap) on FIELD_DEFINITION +type Query { + """ + Returns label information from Arkham, ENS data, and whether the address is an EOA or contract. Arkham data is stored permanently. ENS data is cached with a configurable TTL. + """ + getAddress(address: String!): getAddress_200_response -directive @transport(subgraph: String, kind: String, location: String, headers: [[String]], queryStringOptions: ObjMap, queryParams: [[String]]) repeatable on SCHEMA + """ + Returns label information from Arkham, ENS data, and address type for multiple addresses. Maximum 100 addresses per request. Arkham data is stored permanently. ENS data is cached with a configurable TTL. + """ + getAddresses(addresses: JSON!): getAddresses_200_response -type Query { """ Get historical delegations for an account, with optional filtering and sorting """ @@ -214,6 +224,69 @@ type Query { daos: DAOList! } +type getAddress_200_response { + address: String! + isContract: Boolean! + arkham: query_getAddress_arkham + ens: query_getAddress_ens +} + +type query_getAddress_arkham { + entity: String + entityType: String + label: String + twitter: String +} + +type query_getAddress_ens { + name: String + avatar: String + banner: String +} + +type getAddresses_200_response { + results: [query_getAddresses_results_items]! +} + +type query_getAddresses_results_items { + address: String! + isContract: Boolean! + arkham: query_getAddresses_results_items_arkham + ens: query_getAddresses_results_items_ens +} + +type query_getAddresses_results_items_arkham { + entity: String + entityType: String + label: String + twitter: String +} + +type query_getAddresses_results_items_ens { + name: String + avatar: String + banner: String +} + +""" +The `JSON` scalar type represents JSON values as specified by [ECMA-404](http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf). +""" +scalar JSON @specifiedBy(url: "http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf") + +enum HTTPMethod { + GET + HEAD + POST + PUT + DELETE + CONNECT + OPTIONS + TRACE + PATCH +} + +scalar ObjMap + type historicalDelegations_200_response { items: [query_historicalDelegations_items_items]! totalCount: Float! @@ -227,11 +300,6 @@ type query_historicalDelegations_items_items { transactionHash: String! } -""" -The `JSON` scalar type represents JSON values as specified by [ECMA-404](http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf). -""" -scalar JSON @specifiedBy(url: "http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf") - """Integers that will have a value of 0 or more.""" scalar NonNegativeInt @@ -373,7 +441,7 @@ type query_feedEvents_items_items { txHash: String! logIndex: Float! type: query_feedEvents_items_items_type! - value: String! + value: String timestamp: Float! relevance: query_feedEvents_items_items_relevance! metadata: JSON @@ -1200,20 +1268,6 @@ enum queryInput_tokenMetrics_orderDirection { desc } -scalar ObjMap - -enum HTTPMethod { - GET - HEAD - POST - PUT - DELETE - CONNECT - OPTIONS - TRACE - PATCH -} - type AverageDelegationPercentageItem { date: String! high: String! diff --git a/package.json b/package.json index 0b6593343..93b008561 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "gateway": "dotenv -- pnpm run --filter=@anticapture/api-gateway", "api": "dotenv -- pnpm run --filter=@anticapture/api", "client": "dotenv -- pnpm run --filter=@anticapture/graphql-client", + "address": "dotenv -- pnpm run --filter=@anticapture/address-enrichment", "clean": "turbo clean && rm -rf node_modules .turbo .parcel-cache coverage *.log *.tsbuildinfo", "lint": "turbo lint", "lint:fix": "turbo lint:fix", diff --git a/packages/graphql-client/documents/getAddress.graphql b/packages/graphql-client/documents/getAddress.graphql new file mode 100644 index 000000000..3145e8a9d --- /dev/null +++ b/packages/graphql-client/documents/getAddress.graphql @@ -0,0 +1,17 @@ +query GetAddress($address: String!) { + getAddress(address: $address) { + address + isContract + arkham { + entity + entityType + label + twitter + } + ens { + name + avatar + banner + } + } +} diff --git a/packages/graphql-client/documents/getAddresses.graphql b/packages/graphql-client/documents/getAddresses.graphql new file mode 100644 index 000000000..56f87cbe0 --- /dev/null +++ b/packages/graphql-client/documents/getAddresses.graphql @@ -0,0 +1,19 @@ +query GetAddresses($addresses: JSON!) { + getAddresses(addresses: $addresses) { + results { + address + isContract + arkham { + entity + entityType + label + twitter + } + ens { + name + avatar + banner + } + } + } +} diff --git a/packages/graphql-client/generated.ts b/packages/graphql-client/generated.ts index 8c72bf3d9..b643c6d96 100644 --- a/packages/graphql-client/generated.ts +++ b/packages/graphql-client/generated.ts @@ -15,9 +15,12 @@ export type Scalars = { Boolean: { input: boolean; output: boolean; } Int: { input: number; output: number; } Float: { input: number; output: number; } + /** The `JSON` scalar type represents JSON values as specified by [ECMA-404](http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf). */ JSON: { input: any; output: any; } + /** Integers that will have a value of 0 or more. */ NonNegativeInt: { input: any; output: any; } ObjMap: { input: any; output: any; } + /** Integers that will have a value greater than 0. */ PositiveInt: { input: any; output: any; } }; @@ -117,6 +120,10 @@ export type Query = { delegations?: Maybe; /** Get feed events */ feedEvents?: Maybe; + /** Returns label information from Arkham, ENS data, and whether the address is an EOA or contract. Arkham data is stored permanently. ENS data is cached with a configurable TTL. */ + getAddress?: Maybe; + /** Returns label information from Arkham, ENS data, and address type for multiple addresses. Maximum 100 addresses per request. Arkham data is stored permanently. ENS data is cached with a configurable TTL. */ + getAddresses?: Maybe; /** Get historical DAO Token Treasury value (governance token quantity × token price) */ getDaoTokenTreasury?: Maybe; /** Get historical Liquid Treasury (treasury without DAO tokens) from external providers (DefiLlama/Dune) */ @@ -305,6 +312,16 @@ export type QueryFeedEventsArgs = { }; +export type QueryGetAddressArgs = { + address: Scalars['String']['input']; +}; + + +export type QueryGetAddressesArgs = { + addresses: Scalars['JSON']['input']; +}; + + export type QueryGetDaoTokenTreasuryArgs = { days?: InputMaybe; order?: InputMaybe; @@ -661,6 +678,19 @@ export type FeedEvents_200_Response = { totalCount: Scalars['Float']['output']; }; +export type GetAddress_200_Response = { + __typename?: 'getAddress_200_response'; + address: Scalars['String']['output']; + arkham?: Maybe; + ens?: Maybe; + isContract: Scalars['Boolean']['output']; +}; + +export type GetAddresses_200_Response = { + __typename?: 'getAddresses_200_response'; + results: Array>; +}; + export type GetDaoTokenTreasury_200_Response = { __typename?: 'getDaoTokenTreasury_200_response'; items: Array>; @@ -1181,6 +1211,44 @@ export enum Query_FeedEvents_Items_Items_Type { Vote = 'VOTE' } +export type Query_GetAddress_Arkham = { + __typename?: 'query_getAddress_arkham'; + entity?: Maybe; + entityType?: Maybe; + label?: Maybe; + twitter?: Maybe; +}; + +export type Query_GetAddress_Ens = { + __typename?: 'query_getAddress_ens'; + avatar?: Maybe; + banner?: Maybe; + name?: Maybe; +}; + +export type Query_GetAddresses_Results_Items = { + __typename?: 'query_getAddresses_results_items'; + address: Scalars['String']['output']; + arkham?: Maybe; + ens?: Maybe; + isContract: Scalars['Boolean']['output']; +}; + +export type Query_GetAddresses_Results_Items_Arkham = { + __typename?: 'query_getAddresses_results_items_arkham'; + entity?: Maybe; + entityType?: Maybe; + label?: Maybe; + twitter?: Maybe; +}; + +export type Query_GetAddresses_Results_Items_Ens = { + __typename?: 'query_getAddresses_results_items_ens'; + avatar?: Maybe; + banner?: Maybe; + name?: Maybe; +}; + export type Query_GetDaoTokenTreasury_Items_Items = { __typename?: 'query_getDaoTokenTreasury_items_items'; /** Unix timestamp in milliseconds */ @@ -1732,6 +1800,20 @@ export type GetFeedEventsQueryVariables = Exact<{ export type GetFeedEventsQuery = { __typename?: 'Query', feedEvents?: { __typename?: 'feedEvents_200_response', totalCount: number, items: Array<{ __typename?: 'query_feedEvents_items_items', txHash: string, logIndex: number, type: Query_FeedEvents_Items_Items_Type, value?: string | null, timestamp: number, relevance: Query_FeedEvents_Items_Items_Relevance, metadata?: any | null } | null> } | null }; +export type GetAddressQueryVariables = Exact<{ + address: Scalars['String']['input']; +}>; + + +export type GetAddressQuery = { __typename?: 'Query', getAddress?: { __typename?: 'getAddress_200_response', address: string, isContract: boolean, arkham?: { __typename?: 'query_getAddress_arkham', entity?: string | null, entityType?: string | null, label?: string | null, twitter?: string | null } | null, ens?: { __typename?: 'query_getAddress_ens', name?: string | null, avatar?: string | null, banner?: string | null } | null } | null }; + +export type GetAddressesQueryVariables = Exact<{ + addresses: Scalars['JSON']['input']; +}>; + + +export type GetAddressesQuery = { __typename?: 'Query', getAddresses?: { __typename?: 'getAddresses_200_response', results: Array<{ __typename?: 'query_getAddresses_results_items', address: string, isContract: boolean, arkham?: { __typename?: 'query_getAddresses_results_items_arkham', entity?: string | null, entityType?: string | null, label?: string | null, twitter?: string | null } | null, ens?: { __typename?: 'query_getAddresses_results_items_ens', name?: string | null, avatar?: string | null, banner?: string | null } | null } | null> } | null }; + export type GetProposalsFromDaoQueryVariables = Exact<{ skip?: InputMaybe; limit?: InputMaybe; @@ -2846,6 +2928,118 @@ export type GetFeedEventsQueryHookResult = ReturnType; export type GetFeedEventsSuspenseQueryHookResult = ReturnType; export type GetFeedEventsQueryResult = Apollo.QueryResult; +export const GetAddressDocument = gql` + query GetAddress($address: String!) { + getAddress(address: $address) { + address + isContract + arkham { + entity + entityType + label + twitter + } + ens { + name + avatar + banner + } + } +} + `; + +/** + * __useGetAddressQuery__ + * + * To run a query within a React component, call `useGetAddressQuery` and pass it any options that fit your needs. + * When your component renders, `useGetAddressQuery` returns an object from Apollo Client that contains loading, error, and data properties + * you can use to render your UI. + * + * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options; + * + * @example + * const { data, loading, error } = useGetAddressQuery({ + * variables: { + * address: // value for 'address' + * }, + * }); + */ +export function useGetAddressQuery(baseOptions: Apollo.QueryHookOptions & ({ variables: GetAddressQueryVariables; skip?: boolean; } | { skip: boolean; }) ) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useQuery(GetAddressDocument, options); + } +export function useGetAddressLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useLazyQuery(GetAddressDocument, options); + } +// @ts-ignore +export function useGetAddressSuspenseQuery(baseOptions?: Apollo.SuspenseQueryHookOptions): Apollo.UseSuspenseQueryResult; +export function useGetAddressSuspenseQuery(baseOptions?: Apollo.SkipToken | Apollo.SuspenseQueryHookOptions): Apollo.UseSuspenseQueryResult; +export function useGetAddressSuspenseQuery(baseOptions?: Apollo.SkipToken | Apollo.SuspenseQueryHookOptions) { + const options = baseOptions === Apollo.skipToken ? baseOptions : {...defaultOptions, ...baseOptions} + return Apollo.useSuspenseQuery(GetAddressDocument, options); + } +export type GetAddressQueryHookResult = ReturnType; +export type GetAddressLazyQueryHookResult = ReturnType; +export type GetAddressSuspenseQueryHookResult = ReturnType; +export type GetAddressQueryResult = Apollo.QueryResult; +export const GetAddressesDocument = gql` + query GetAddresses($addresses: JSON!) { + getAddresses(addresses: $addresses) { + results { + address + isContract + arkham { + entity + entityType + label + twitter + } + ens { + name + avatar + banner + } + } + } +} + `; + +/** + * __useGetAddressesQuery__ + * + * To run a query within a React component, call `useGetAddressesQuery` and pass it any options that fit your needs. + * When your component renders, `useGetAddressesQuery` returns an object from Apollo Client that contains loading, error, and data properties + * you can use to render your UI. + * + * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options; + * + * @example + * const { data, loading, error } = useGetAddressesQuery({ + * variables: { + * addresses: // value for 'addresses' + * }, + * }); + */ +export function useGetAddressesQuery(baseOptions: Apollo.QueryHookOptions & ({ variables: GetAddressesQueryVariables; skip?: boolean; } | { skip: boolean; }) ) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useQuery(GetAddressesDocument, options); + } +export function useGetAddressesLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useLazyQuery(GetAddressesDocument, options); + } +// @ts-ignore +export function useGetAddressesSuspenseQuery(baseOptions?: Apollo.SuspenseQueryHookOptions): Apollo.UseSuspenseQueryResult; +export function useGetAddressesSuspenseQuery(baseOptions?: Apollo.SkipToken | Apollo.SuspenseQueryHookOptions): Apollo.UseSuspenseQueryResult; +export function useGetAddressesSuspenseQuery(baseOptions?: Apollo.SkipToken | Apollo.SuspenseQueryHookOptions) { + const options = baseOptions === Apollo.skipToken ? baseOptions : {...defaultOptions, ...baseOptions} + return Apollo.useSuspenseQuery(GetAddressesDocument, options); + } +export type GetAddressesQueryHookResult = ReturnType; +export type GetAddressesLazyQueryHookResult = ReturnType; +export type GetAddressesSuspenseQueryHookResult = ReturnType; +export type GetAddressesQueryResult = Apollo.QueryResult; export const GetProposalsFromDaoDocument = gql` query GetProposalsFromDao($skip: NonNegativeInt, $limit: PositiveInt = 10, $orderDirection: queryInput_proposals_orderDirection = desc, $status: JSON, $fromDate: Float) { proposals( diff --git a/packages/graphql-client/types.ts b/packages/graphql-client/types.ts index e277d91f4..34cda5241 100644 --- a/packages/graphql-client/types.ts +++ b/packages/graphql-client/types.ts @@ -12,9 +12,12 @@ export type Scalars = { Boolean: { input: boolean; output: boolean; } Int: { input: number; output: number; } Float: { input: number; output: number; } + /** The `JSON` scalar type represents JSON values as specified by [ECMA-404](http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf). */ JSON: { input: any; output: any; } + /** Integers that will have a value of 0 or more. */ NonNegativeInt: { input: any; output: any; } ObjMap: { input: any; output: any; } + /** Integers that will have a value greater than 0. */ PositiveInt: { input: any; output: any; } }; @@ -114,6 +117,10 @@ export type Query = { delegations?: Maybe; /** Get feed events */ feedEvents?: Maybe; + /** Returns label information from Arkham, ENS data, and whether the address is an EOA or contract. Arkham data is stored permanently. ENS data is cached with a configurable TTL. */ + getAddress?: Maybe; + /** Returns label information from Arkham, ENS data, and address type for multiple addresses. Maximum 100 addresses per request. Arkham data is stored permanently. ENS data is cached with a configurable TTL. */ + getAddresses?: Maybe; /** Get historical DAO Token Treasury value (governance token quantity × token price) */ getDaoTokenTreasury?: Maybe; /** Get historical Liquid Treasury (treasury without DAO tokens) from external providers (DefiLlama/Dune) */ @@ -302,6 +309,16 @@ export type QueryFeedEventsArgs = { }; +export type QueryGetAddressArgs = { + address: Scalars['String']['input']; +}; + + +export type QueryGetAddressesArgs = { + addresses: Scalars['JSON']['input']; +}; + + export type QueryGetDaoTokenTreasuryArgs = { days?: InputMaybe; order?: InputMaybe; @@ -658,6 +675,19 @@ export type FeedEvents_200_Response = { totalCount: Scalars['Float']['output']; }; +export type GetAddress_200_Response = { + __typename?: 'getAddress_200_response'; + address: Scalars['String']['output']; + arkham?: Maybe; + ens?: Maybe; + isContract: Scalars['Boolean']['output']; +}; + +export type GetAddresses_200_Response = { + __typename?: 'getAddresses_200_response'; + results: Array>; +}; + export type GetDaoTokenTreasury_200_Response = { __typename?: 'getDaoTokenTreasury_200_response'; items: Array>; @@ -1178,6 +1208,44 @@ export enum Query_FeedEvents_Items_Items_Type { Vote = 'VOTE' } +export type Query_GetAddress_Arkham = { + __typename?: 'query_getAddress_arkham'; + entity?: Maybe; + entityType?: Maybe; + label?: Maybe; + twitter?: Maybe; +}; + +export type Query_GetAddress_Ens = { + __typename?: 'query_getAddress_ens'; + avatar?: Maybe; + banner?: Maybe; + name?: Maybe; +}; + +export type Query_GetAddresses_Results_Items = { + __typename?: 'query_getAddresses_results_items'; + address: Scalars['String']['output']; + arkham?: Maybe; + ens?: Maybe; + isContract: Scalars['Boolean']['output']; +}; + +export type Query_GetAddresses_Results_Items_Arkham = { + __typename?: 'query_getAddresses_results_items_arkham'; + entity?: Maybe; + entityType?: Maybe; + label?: Maybe; + twitter?: Maybe; +}; + +export type Query_GetAddresses_Results_Items_Ens = { + __typename?: 'query_getAddresses_results_items_ens'; + avatar?: Maybe; + banner?: Maybe; + name?: Maybe; +}; + export type Query_GetDaoTokenTreasury_Items_Items = { __typename?: 'query_getDaoTokenTreasury_items_items'; /** Unix timestamp in milliseconds */ @@ -1729,6 +1797,20 @@ export type GetFeedEventsQueryVariables = Exact<{ export type GetFeedEventsQuery = { __typename?: 'Query', feedEvents?: { __typename?: 'feedEvents_200_response', totalCount: number, items: Array<{ __typename?: 'query_feedEvents_items_items', txHash: string, logIndex: number, type: Query_FeedEvents_Items_Items_Type, value?: string | null, timestamp: number, relevance: Query_FeedEvents_Items_Items_Relevance, metadata?: any | null } | null> } | null }; +export type GetAddressQueryVariables = Exact<{ + address: Scalars['String']['input']; +}>; + + +export type GetAddressQuery = { __typename?: 'Query', getAddress?: { __typename?: 'getAddress_200_response', address: string, isContract: boolean, arkham?: { __typename?: 'query_getAddress_arkham', entity?: string | null, entityType?: string | null, label?: string | null, twitter?: string | null } | null, ens?: { __typename?: 'query_getAddress_ens', name?: string | null, avatar?: string | null, banner?: string | null } | null } | null }; + +export type GetAddressesQueryVariables = Exact<{ + addresses: Scalars['JSON']['input']; +}>; + + +export type GetAddressesQuery = { __typename?: 'Query', getAddresses?: { __typename?: 'getAddresses_200_response', results: Array<{ __typename?: 'query_getAddresses_results_items', address: string, isContract: boolean, arkham?: { __typename?: 'query_getAddresses_results_items_arkham', entity?: string | null, entityType?: string | null, label?: string | null, twitter?: string | null } | null, ens?: { __typename?: 'query_getAddresses_results_items_ens', name?: string | null, avatar?: string | null, banner?: string | null } | null } | null> } | null }; + export type GetProposalsFromDaoQueryVariables = Exact<{ skip?: InputMaybe; limit?: InputMaybe; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4b1fe718a..290164cb5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -56,6 +56,67 @@ importers: specifier: ^5.8.3 version: 5.9.3 + apps/address-enrichment: + dependencies: + "@hono/node-server": + specifier: ^1.13.7 + version: 1.19.9(hono@4.11.9) + "@hono/swagger-ui": + specifier: ^0.5.1 + version: 0.5.3(hono@4.11.9) + "@hono/zod-openapi": + specifier: ^0.19.6 + version: 0.19.10(hono@4.11.9)(zod@3.25.76) + dotenv: + specifier: ^17.2.4 + version: 17.2.4 + drizzle-kit: + specifier: ^0.31.4 + version: 0.31.9 + drizzle-orm: + specifier: ~0.41.0 + version: 0.41.0(@electric-sql/pglite@0.3.15)(@opentelemetry/api@1.9.0)(@types/pg@8.16.0)(kysely@0.26.3)(pg@8.18.0) + hono: + specifier: ^4.7.10 + version: 4.11.9 + pg: + specifier: ^8.13.1 + version: 8.18.0 + viem: + specifier: ^2.37.11 + version: 2.45.3(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + zod: + specifier: ^3.25.3 + version: 3.25.76 + devDependencies: + "@types/node": + specifier: ^20.16.5 + version: 20.19.33 + "@types/pg": + specifier: ^8.11.10 + version: 8.16.0 + eslint: + specifier: ^8.53.0 + version: 8.57.1 + eslint-config-prettier: + specifier: ^9.1.0 + version: 9.1.2(eslint@8.57.1) + eslint-plugin-prettier: + specifier: ^5.2.1 + version: 5.5.5(@types/eslint@9.6.1)(eslint-config-prettier@9.1.2(eslint@8.57.1))(eslint@8.57.1)(prettier@3.8.1) + prettier: + specifier: ^3.5.3 + version: 3.8.1 + tsup: + specifier: ^8.5.1 + version: 8.5.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) + tsx: + specifier: ^4.19.4 + version: 4.21.0 + typescript: + specifier: ^5.8.3 + version: 5.9.3 + apps/api: dependencies: "@hono/node-server": @@ -260,10 +321,10 @@ importers: version: 7.7.17(react@19.2.3) next: specifier: 16.1.3 - version: 16.1.3(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + version: 16.1.3(@babel/core@7.28.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) nuqs: specifier: ^2.8.4 - version: 2.8.8(next@16.1.3(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3) + version: 2.8.8(next@16.1.3(@babel/core@7.28.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3) react: specifier: 19.2.3 version: 19.2.3 @@ -330,7 +391,7 @@ importers: version: 10.2.8(@vitest/runner@3.2.4)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(storybook@10.2.8(@testing-library/dom@10.4.0)(bufferutil@4.0.9)(prettier@3.8.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(utf-8-validate@5.0.10))(vitest@3.2.4(@types/debug@4.1.12)(@types/node@20.19.33)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) "@storybook/nextjs": specifier: ^10.2.0 - version: 10.2.8(next@16.1.3(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(storybook@10.2.8(@testing-library/dom@10.4.0)(bufferutil@4.0.9)(prettier@3.8.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(utf-8-validate@5.0.10))(type-fest@4.41.0)(typescript@5.9.3)(webpack-hot-middleware@2.26.1)(webpack@5.105.1) + version: 10.2.8(next@16.1.3(@babel/core@7.28.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(storybook@10.2.8(@testing-library/dom@10.4.0)(bufferutil@4.0.9)(prettier@3.8.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(utf-8-validate@5.0.10))(type-fest@4.41.0)(typescript@5.9.3)(webpack-hot-middleware@2.26.1)(webpack@5.105.1) "@storybook/react": specifier: ^10.2.0 version: 10.2.8(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(storybook@10.2.8(@testing-library/dom@10.4.0)(bufferutil@4.0.9)(prettier@3.8.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(utf-8-validate@5.0.10))(typescript@5.9.3) @@ -393,7 +454,7 @@ importers: version: 4.1.18 ts-jest: specifier: ^29.2.6 - version: 29.4.6(@babel/core@7.28.5)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.5))(jest-util@29.7.0)(jest@29.7.0(@types/node@20.19.33)(ts-node@10.9.2(@types/node@20.19.33)(typescript@5.9.3)))(typescript@5.9.3) + version: 29.4.6(@babel/core@7.28.0)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.0))(jest-util@29.7.0)(jest@29.7.0(@types/node@20.19.33)(ts-node@10.9.2(@types/node@20.19.33)(typescript@5.9.3)))(typescript@5.9.3) tw-animate-css: specifier: ^1.3.0 version: 1.4.0 @@ -4739,6 +4800,14 @@ packages: peerDependencies: hono: ^4 + "@hono/swagger-ui@0.5.3": + resolution: + { + integrity: sha512-Hn90DOOJ62ICJQplQvCDVpi9Jcn6EhtRaiffyJIS53wA5RmRLtMCDQGVc0bor8vQD7JIwpkweWjs+3cycp+IvA==, + } + peerDependencies: + hono: ">=4.0.0" + "@hono/zod-openapi@0.19.10": resolution: { @@ -19695,12 +19764,6 @@ snapshots: "@babel/core": 7.28.0 "@babel/helper-plugin-utils": 7.27.1 - "@babel/plugin-syntax-async-generators@7.8.4(@babel/core@7.28.5)": - dependencies: - "@babel/core": 7.28.5 - "@babel/helper-plugin-utils": 7.27.1 - optional: true - "@babel/plugin-syntax-async-generators@7.8.4(@babel/core@7.29.0)": dependencies: "@babel/core": 7.29.0 @@ -19744,12 +19807,6 @@ snapshots: "@babel/core": 7.28.0 "@babel/helper-plugin-utils": 7.27.1 - "@babel/plugin-syntax-class-static-block@7.14.5(@babel/core@7.28.5)": - dependencies: - "@babel/core": 7.28.5 - "@babel/helper-plugin-utils": 7.27.1 - optional: true - "@babel/plugin-syntax-class-static-block@7.14.5(@babel/core@7.29.0)": dependencies: "@babel/core": 7.29.0 @@ -19791,12 +19848,6 @@ snapshots: "@babel/core": 7.28.0 "@babel/helper-plugin-utils": 7.27.1 - "@babel/plugin-syntax-import-attributes@7.27.1(@babel/core@7.28.5)": - dependencies: - "@babel/core": 7.28.5 - "@babel/helper-plugin-utils": 7.27.1 - optional: true - "@babel/plugin-syntax-import-attributes@7.27.1(@babel/core@7.29.0)": dependencies: "@babel/core": 7.29.0 @@ -19813,12 +19864,6 @@ snapshots: "@babel/core": 7.28.0 "@babel/helper-plugin-utils": 7.27.1 - "@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.28.5)": - dependencies: - "@babel/core": 7.28.5 - "@babel/helper-plugin-utils": 7.27.1 - optional: true - "@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.29.0)": dependencies: "@babel/core": 7.29.0 @@ -19830,12 +19875,6 @@ snapshots: "@babel/core": 7.28.0 "@babel/helper-plugin-utils": 7.27.1 - "@babel/plugin-syntax-json-strings@7.8.3(@babel/core@7.28.5)": - dependencies: - "@babel/core": 7.28.5 - "@babel/helper-plugin-utils": 7.27.1 - optional: true - "@babel/plugin-syntax-json-strings@7.8.3(@babel/core@7.29.0)": dependencies: "@babel/core": 7.29.0 @@ -19857,12 +19896,6 @@ snapshots: "@babel/core": 7.28.0 "@babel/helper-plugin-utils": 7.27.1 - "@babel/plugin-syntax-logical-assignment-operators@7.10.4(@babel/core@7.28.5)": - dependencies: - "@babel/core": 7.28.5 - "@babel/helper-plugin-utils": 7.27.1 - optional: true - "@babel/plugin-syntax-logical-assignment-operators@7.10.4(@babel/core@7.29.0)": dependencies: "@babel/core": 7.29.0 @@ -19874,12 +19907,6 @@ snapshots: "@babel/core": 7.28.0 "@babel/helper-plugin-utils": 7.27.1 - "@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.28.5)": - dependencies: - "@babel/core": 7.28.5 - "@babel/helper-plugin-utils": 7.27.1 - optional: true - "@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.29.0)": dependencies: "@babel/core": 7.29.0 @@ -19891,12 +19918,6 @@ snapshots: "@babel/core": 7.28.0 "@babel/helper-plugin-utils": 7.27.1 - "@babel/plugin-syntax-numeric-separator@7.10.4(@babel/core@7.28.5)": - dependencies: - "@babel/core": 7.28.5 - "@babel/helper-plugin-utils": 7.27.1 - optional: true - "@babel/plugin-syntax-numeric-separator@7.10.4(@babel/core@7.29.0)": dependencies: "@babel/core": 7.29.0 @@ -19924,12 +19945,6 @@ snapshots: "@babel/core": 7.28.0 "@babel/helper-plugin-utils": 7.27.1 - "@babel/plugin-syntax-optional-catch-binding@7.8.3(@babel/core@7.28.5)": - dependencies: - "@babel/core": 7.28.5 - "@babel/helper-plugin-utils": 7.27.1 - optional: true - "@babel/plugin-syntax-optional-catch-binding@7.8.3(@babel/core@7.29.0)": dependencies: "@babel/core": 7.29.0 @@ -19941,12 +19956,6 @@ snapshots: "@babel/core": 7.28.0 "@babel/helper-plugin-utils": 7.27.1 - "@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.28.5)": - dependencies: - "@babel/core": 7.28.5 - "@babel/helper-plugin-utils": 7.27.1 - optional: true - "@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.29.0)": dependencies: "@babel/core": 7.29.0 @@ -19958,12 +19967,6 @@ snapshots: "@babel/core": 7.28.0 "@babel/helper-plugin-utils": 7.27.1 - "@babel/plugin-syntax-private-property-in-object@7.14.5(@babel/core@7.28.5)": - dependencies: - "@babel/core": 7.28.5 - "@babel/helper-plugin-utils": 7.27.1 - optional: true - "@babel/plugin-syntax-private-property-in-object@7.14.5(@babel/core@7.29.0)": dependencies: "@babel/core": 7.29.0 @@ -19975,12 +19978,6 @@ snapshots: "@babel/core": 7.28.0 "@babel/helper-plugin-utils": 7.27.1 - "@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.28.5)": - dependencies: - "@babel/core": 7.28.5 - "@babel/helper-plugin-utils": 7.27.1 - optional: true - "@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.29.0)": dependencies: "@babel/core": 7.29.0 @@ -23058,6 +23055,10 @@ snapshots: dependencies: hono: 4.11.9 + "@hono/swagger-ui@0.5.3(hono@4.11.9)": + dependencies: + hono: 4.11.9 + "@hono/zod-openapi@0.19.10(hono@4.11.9)(zod@3.25.76)": dependencies: "@asteasolutions/zod-to-openapi": 7.3.4(zod@3.25.76) @@ -23222,7 +23223,7 @@ snapshots: "@jest/console@29.7.0": dependencies: "@jest/types": 29.6.3 - "@types/node": 25.2.3 + "@types/node": 20.19.33 chalk: 4.1.2 jest-message-util: 29.7.0 jest-util: 29.7.0 @@ -23235,14 +23236,14 @@ snapshots: "@jest/test-result": 29.7.0 "@jest/transform": 29.7.0 "@jest/types": 29.6.3 - "@types/node": 25.2.3 + "@types/node": 20.19.33 ansi-escapes: 4.3.2 chalk: 4.1.2 ci-info: 3.9.0 exit: 0.1.2 graceful-fs: 4.2.11 jest-changed-files: 29.7.0 - jest-config: 29.7.0(@types/node@25.2.3)(ts-node@10.9.2(@types/node@20.19.33)(typescript@5.9.3)) + jest-config: 29.7.0(@types/node@20.19.33)(ts-node@10.9.2(@types/node@20.19.33)(typescript@5.9.3)) jest-haste-map: 29.7.0 jest-message-util: 29.7.0 jest-regex-util: 29.6.3 @@ -23270,14 +23271,14 @@ snapshots: "@jest/test-result": 29.7.0 "@jest/transform": 29.7.0 "@jest/types": 29.6.3 - "@types/node": 25.2.3 + "@types/node": 20.19.33 ansi-escapes: 4.3.2 chalk: 4.1.2 ci-info: 3.9.0 exit: 0.1.2 graceful-fs: 4.2.11 jest-changed-files: 29.7.0 - jest-config: 29.7.0(@types/node@25.2.3)(ts-node@10.9.2(@types/node@25.2.3)(typescript@5.9.3)) + jest-config: 29.7.0(@types/node@20.19.33)(ts-node@10.9.2(@types/node@25.2.3)(typescript@5.9.3)) jest-haste-map: 29.7.0 jest-message-util: 29.7.0 jest-regex-util: 29.6.3 @@ -23302,7 +23303,7 @@ snapshots: dependencies: "@jest/fake-timers": 29.7.0 "@jest/types": 29.6.3 - "@types/node": 25.2.3 + "@types/node": 20.19.33 jest-mock: 29.7.0 "@jest/expect-utils@29.7.0": @@ -23320,7 +23321,7 @@ snapshots: dependencies: "@jest/types": 29.6.3 "@sinonjs/fake-timers": 10.3.0 - "@types/node": 25.2.3 + "@types/node": 20.19.33 jest-message-util: 29.7.0 jest-mock: 29.7.0 jest-util: 29.7.0 @@ -23342,7 +23343,7 @@ snapshots: "@jest/transform": 29.7.0 "@jest/types": 29.6.3 "@jridgewell/trace-mapping": 0.3.31 - "@types/node": 25.2.3 + "@types/node": 20.19.33 chalk: 4.1.2 collect-v8-coverage: 1.0.2 exit: 0.1.2 @@ -23412,7 +23413,7 @@ snapshots: "@jest/schemas": 29.6.3 "@types/istanbul-lib-coverage": 2.0.6 "@types/istanbul-reports": 3.0.4 - "@types/node": 25.2.3 + "@types/node": 20.19.33 "@types/yargs": 17.0.33 chalk: 4.1.2 @@ -25211,7 +25212,7 @@ snapshots: react: 19.2.3 react-dom: 19.2.3(react@19.2.3) - "@storybook/nextjs@10.2.8(next@16.1.3(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(storybook@10.2.8(@testing-library/dom@10.4.0)(bufferutil@4.0.9)(prettier@3.8.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(utf-8-validate@5.0.10))(type-fest@4.41.0)(typescript@5.9.3)(webpack-hot-middleware@2.26.1)(webpack@5.105.1)": + "@storybook/nextjs@10.2.8(next@16.1.3(@babel/core@7.28.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(storybook@10.2.8(@testing-library/dom@10.4.0)(bufferutil@4.0.9)(prettier@3.8.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(utf-8-validate@5.0.10))(type-fest@4.41.0)(typescript@5.9.3)(webpack-hot-middleware@2.26.1)(webpack@5.105.1)": dependencies: "@babel/core": 7.28.5 "@babel/plugin-syntax-bigint": 7.8.3(@babel/core@7.28.5) @@ -25235,7 +25236,7 @@ snapshots: css-loader: 6.11.0(webpack@5.105.1) image-size: 2.0.2 loader-utils: 3.3.1 - next: 16.1.3(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + next: 16.1.3(@babel/core@7.28.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) node-polyfill-webpack-plugin: 2.0.1(webpack@5.105.1) postcss: 8.5.6 postcss-loader: 8.1.1(postcss@8.5.6)(typescript@5.9.3)(webpack@5.105.1) @@ -25557,7 +25558,7 @@ snapshots: "@types/graceful-fs@4.1.9": dependencies: - "@types/node": 25.2.3 + "@types/node": 20.19.33 "@types/html-minifier-terser@6.1.0": {} @@ -25599,6 +25600,7 @@ snapshots: "@types/node@25.2.3": dependencies: undici-types: 7.16.0 + optional: true "@types/pg@8.16.0": dependencies: @@ -27097,20 +27099,6 @@ snapshots: transitivePeerDependencies: - supports-color - babel-jest@29.7.0(@babel/core@7.28.5): - dependencies: - "@babel/core": 7.28.5 - "@jest/transform": 29.7.0 - "@types/babel__core": 7.20.5 - babel-plugin-istanbul: 6.1.1 - babel-preset-jest: 29.6.3(@babel/core@7.28.5) - chalk: 4.1.2 - graceful-fs: 4.2.11 - slash: 3.0.0 - transitivePeerDependencies: - - supports-color - optional: true - babel-jest@29.7.0(@babel/core@7.29.0): dependencies: "@babel/core": 7.29.0 @@ -27194,26 +27182,6 @@ snapshots: "@babel/plugin-syntax-private-property-in-object": 7.14.5(@babel/core@7.28.0) "@babel/plugin-syntax-top-level-await": 7.14.5(@babel/core@7.28.0) - babel-preset-current-node-syntax@1.1.0(@babel/core@7.28.5): - dependencies: - "@babel/core": 7.28.5 - "@babel/plugin-syntax-async-generators": 7.8.4(@babel/core@7.28.5) - "@babel/plugin-syntax-bigint": 7.8.3(@babel/core@7.28.5) - "@babel/plugin-syntax-class-properties": 7.12.13(@babel/core@7.28.5) - "@babel/plugin-syntax-class-static-block": 7.14.5(@babel/core@7.28.5) - "@babel/plugin-syntax-import-attributes": 7.27.1(@babel/core@7.28.5) - "@babel/plugin-syntax-import-meta": 7.10.4(@babel/core@7.28.5) - "@babel/plugin-syntax-json-strings": 7.8.3(@babel/core@7.28.5) - "@babel/plugin-syntax-logical-assignment-operators": 7.10.4(@babel/core@7.28.5) - "@babel/plugin-syntax-nullish-coalescing-operator": 7.8.3(@babel/core@7.28.5) - "@babel/plugin-syntax-numeric-separator": 7.10.4(@babel/core@7.28.5) - "@babel/plugin-syntax-object-rest-spread": 7.8.3(@babel/core@7.28.5) - "@babel/plugin-syntax-optional-catch-binding": 7.8.3(@babel/core@7.28.5) - "@babel/plugin-syntax-optional-chaining": 7.8.3(@babel/core@7.28.5) - "@babel/plugin-syntax-private-property-in-object": 7.14.5(@babel/core@7.28.5) - "@babel/plugin-syntax-top-level-await": 7.14.5(@babel/core@7.28.5) - optional: true - babel-preset-current-node-syntax@1.1.0(@babel/core@7.29.0): dependencies: "@babel/core": 7.29.0 @@ -27273,13 +27241,6 @@ snapshots: babel-plugin-jest-hoist: 29.6.3 babel-preset-current-node-syntax: 1.1.0(@babel/core@7.28.0) - babel-preset-jest@29.6.3(@babel/core@7.28.5): - dependencies: - "@babel/core": 7.28.5 - babel-plugin-jest-hoist: 29.6.3 - babel-preset-current-node-syntax: 1.1.0(@babel/core@7.28.5) - optional: true - babel-preset-jest@29.6.3(@babel/core@7.29.0): dependencies: "@babel/core": 7.29.0 @@ -28182,6 +28143,14 @@ snapshots: kysely: 0.26.3 pg: 8.18.0 + drizzle-orm@0.41.0(@electric-sql/pglite@0.3.15)(@opentelemetry/api@1.9.0)(@types/pg@8.16.0)(kysely@0.26.3)(pg@8.18.0): + optionalDependencies: + "@electric-sql/pglite": 0.3.15 + "@opentelemetry/api": 1.9.0 + "@types/pg": 8.16.0 + kysely: 0.26.3 + pg: 8.18.0 + drizzle-orm@0.45.1(@electric-sql/pglite@0.3.15)(@opentelemetry/api@1.9.0)(@types/pg@8.16.0)(kysely@0.26.3)(pg@8.18.0): optionalDependencies: "@electric-sql/pglite": 0.3.15 @@ -30044,7 +30013,7 @@ snapshots: "@jest/expect": 29.7.0 "@jest/test-result": 29.7.0 "@jest/types": 29.6.3 - "@types/node": 25.2.3 + "@types/node": 20.19.33 chalk: 4.1.2 co: 4.6.0 dedent: 1.6.0 @@ -30133,7 +30102,7 @@ snapshots: - babel-plugin-macros - supports-color - jest-config@29.7.0(@types/node@25.2.3)(ts-node@10.9.2(@types/node@20.19.33)(typescript@5.9.3)): + jest-config@29.7.0(@types/node@20.19.33)(ts-node@10.9.2(@types/node@25.2.3)(typescript@5.9.3)): dependencies: "@babel/core": 7.28.0 "@jest/test-sequencer": 29.7.0 @@ -30158,8 +30127,8 @@ snapshots: slash: 3.0.0 strip-json-comments: 3.1.1 optionalDependencies: - "@types/node": 25.2.3 - ts-node: 10.9.2(@types/node@20.19.33)(typescript@5.9.3) + "@types/node": 20.19.33 + ts-node: 10.9.2(@types/node@25.2.3)(typescript@5.9.3) transitivePeerDependencies: - babel-plugin-macros - supports-color @@ -30219,7 +30188,7 @@ snapshots: "@jest/environment": 29.7.0 "@jest/fake-timers": 29.7.0 "@jest/types": 29.6.3 - "@types/node": 25.2.3 + "@types/node": 20.19.33 jest-mock: 29.7.0 jest-util: 29.7.0 @@ -30229,7 +30198,7 @@ snapshots: dependencies: "@jest/types": 29.6.3 "@types/graceful-fs": 4.1.9 - "@types/node": 25.2.3 + "@types/node": 20.19.33 anymatch: 3.1.3 fb-watchman: 2.0.2 graceful-fs: 4.2.11 @@ -30268,7 +30237,7 @@ snapshots: jest-mock@29.7.0: dependencies: "@jest/types": 29.6.3 - "@types/node": 25.2.3 + "@types/node": 20.19.33 jest-util: 29.7.0 jest-pnp-resolver@1.2.3(jest-resolve@29.7.0): @@ -30303,7 +30272,7 @@ snapshots: "@jest/test-result": 29.7.0 "@jest/transform": 29.7.0 "@jest/types": 29.6.3 - "@types/node": 25.2.3 + "@types/node": 20.19.33 chalk: 4.1.2 emittery: 0.13.1 graceful-fs: 4.2.11 @@ -30331,7 +30300,7 @@ snapshots: "@jest/test-result": 29.7.0 "@jest/transform": 29.7.0 "@jest/types": 29.6.3 - "@types/node": 25.2.3 + "@types/node": 20.19.33 chalk: 4.1.2 cjs-module-lexer: 1.4.3 collect-v8-coverage: 1.0.2 @@ -30377,7 +30346,7 @@ snapshots: jest-util@29.7.0: dependencies: "@jest/types": 29.6.3 - "@types/node": 25.2.3 + "@types/node": 20.19.33 chalk: 4.1.2 ci-info: 3.9.0 graceful-fs: 4.2.11 @@ -30396,7 +30365,7 @@ snapshots: dependencies: "@jest/test-result": 29.7.0 "@jest/types": 29.6.3 - "@types/node": 25.2.3 + "@types/node": 20.19.33 ansi-escapes: 4.3.2 chalk: 4.1.2 emittery: 0.13.1 @@ -30411,7 +30380,7 @@ snapshots: jest-worker@29.7.0: dependencies: - "@types/node": 25.2.3 + "@types/node": 20.19.33 jest-util: 29.7.0 merge-stream: 2.0.0 supports-color: 8.1.1 @@ -30970,7 +30939,7 @@ snapshots: neo-async@2.6.2: {} - next@16.1.3(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3): + next@16.1.3(@babel/core@7.28.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3): dependencies: "@next/env": 16.1.3 "@swc/helpers": 0.5.15 @@ -30979,7 +30948,7 @@ snapshots: postcss: 8.4.31 react: 19.2.3 react-dom: 19.2.3(react@19.2.3) - styled-jsx: 5.1.6(@babel/core@7.28.5)(react@19.2.3) + styled-jsx: 5.1.6(@babel/core@7.28.0)(react@19.2.3) optionalDependencies: "@next/swc-darwin-arm64": 16.1.3 "@next/swc-darwin-x64": 16.1.3 @@ -31075,12 +31044,12 @@ snapshots: nullthrows@1.1.1: {} - nuqs@2.8.8(next@16.1.3(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3): + nuqs@2.8.8(next@16.1.3(@babel/core@7.28.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3): dependencies: "@standard-schema/spec": 1.0.0 react: 19.2.3 optionalDependencies: - next: 16.1.3(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + next: 16.1.3(@babel/core@7.28.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) obj-multiplex@1.0.0: dependencies: @@ -32293,7 +32262,7 @@ snapshots: "@types/uuid": 8.3.4 "@types/ws": 8.18.1 buffer: 6.0.3 - eventemitter3: 5.0.1 + eventemitter3: 5.0.4 uuid: 8.3.2 ws: 8.19.0(bufferutil@4.0.9)(utf-8-validate@5.0.10) optionalDependencies: @@ -32789,12 +32758,12 @@ snapshots: dependencies: webpack: 5.105.1 - styled-jsx@5.1.6(@babel/core@7.28.5)(react@19.2.3): + styled-jsx@5.1.6(@babel/core@7.28.0)(react@19.2.3): dependencies: client-only: 0.0.1 react: 19.2.3 optionalDependencies: - "@babel/core": 7.28.5 + "@babel/core": 7.28.0 styled-jsx@5.1.7(@babel/core@7.28.5)(react@19.2.3): dependencies: @@ -33012,7 +32981,7 @@ snapshots: dependencies: tslib: 2.8.1 - ts-jest@29.4.6(@babel/core@7.28.5)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.5))(jest-util@29.7.0)(jest@29.7.0(@types/node@20.19.33)(ts-node@10.9.2(@types/node@20.19.33)(typescript@5.9.3)))(typescript@5.9.3): + ts-jest@29.4.6(@babel/core@7.28.0)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.0))(jest-util@29.7.0)(jest@29.7.0(@types/node@20.19.33)(ts-node@10.9.2(@types/node@20.19.33)(typescript@5.9.3)))(typescript@5.9.3): dependencies: bs-logger: 0.2.6 fast-json-stable-stringify: 2.1.0 @@ -33026,10 +32995,10 @@ snapshots: typescript: 5.9.3 yargs-parser: 21.1.1 optionalDependencies: - "@babel/core": 7.28.5 + "@babel/core": 7.28.0 "@jest/transform": 29.7.0 "@jest/types": 29.6.3 - babel-jest: 29.7.0(@babel/core@7.28.5) + babel-jest: 29.7.0(@babel/core@7.28.0) jest-util: 29.7.0 ts-jest@29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@29.7.0)(jest@29.7.0(@types/node@25.2.3)(ts-node@10.9.2(@types/node@25.2.3)(typescript@5.9.3)))(typescript@5.9.3): @@ -33287,7 +33256,8 @@ snapshots: undici-types@6.21.0: {} - undici-types@7.16.0: {} + undici-types@7.16.0: + optional: true undici-types@7.21.0: {}