diff --git a/.env.example b/.env.example index 73ef91c18..12b1b03df 100644 --- a/.env.example +++ b/.env.example @@ -23,6 +23,7 @@ PERSONAL_TENDERLY_API_KEY= PERSONAL_ACCOUNT_SLUG= PERSONAL_PROJECT_SLUG= PERSONAL_TENDERLY_RPC_NAME= +TENDERLY_TEST_TX_FROM_ADDRESS= VITE_ALCHEMY_KEY= VITE_INFURA_PROJECT_ID= @@ -46,9 +47,15 @@ VITE_PLAUSIBLE_TRACK_LOCALHOST= # Enso routing: set to 'true' to disable Enso zaps, forcing direct deposit/withdraw only VITE_ENSO_DISABLED= +# Holdings History API (server-only) +ENVIO_GRAPHQL_URL=http://localhost:8080/v1/graphql +ENVIO_PASSWORD=testing +HOLDINGS_TEST_WALLET_ADDRESS= +# Holdings storage +UPSTASH_REDIS_REST_URL_PORTFOLIO= +UPSTASH_REDIS_REST_TOKEN_PORTFOLIO= + # Optimization API (migrated from optimization-visualizer) UPSTASH_REDIS_REST_URL= UPSTASH_REDIS_REST_USERNAME= UPSTASH_REDIS_REST_TOKEN= -ENVIO_GRAPHQL_URL= -ENVIO_PASSWORD= diff --git a/.gitignore b/.gitignore index abbe96a3e..129798d8b 100755 --- a/.gitignore +++ b/.gitignore @@ -40,8 +40,8 @@ _app .sentryclirc # docs -/docs -AGENTS.md +/docs/plans +/docs/temp # playwright .playwright-mcp @@ -57,3 +57,4 @@ yearn.fi-worktree-* # codex settings .codex .codex/* +.gstack/ diff --git a/AGENTS.md b/AGENTS.md index b85c1b2a6..3cf6ef04d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,3 +1,10 @@ # Repository Guidelines See `CLAUDE.md` for the canonical project structure, commands, and workflow guidance. + +## GitHub PR workflow + +- For GitHub write operations in this repo, prefer GitHub CLI (`gh`) over the Codex GitHub connector. +- For pull request creation, use `gh pr create` by default. +- Do not use `codex_apps.github_create_pull_request` / `mcp__codex_apps__github_create_pull_request` unless the user explicitly asks to test or debug the connector. +- You may still use GitHub connector tools for read-only lookup when useful. diff --git a/CLAUDE.md b/CLAUDE.md index 195593384..a3d7005d3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,7 +6,8 @@ Yearn Finance vaults interface — React 19 + TypeScript SPA (Vite, TanStack Que ```bash bun install # Install dependencies -bun run dev # Vite dev server (3000) + API server (3001) +bun run dev # Vite dev server + Bun API server; prompts for an API port when needed +bun run preview # Vite preview + Bun API server; prompts for an API port when needed bun run build # TypeScript check + Vite build bun run test # Full Vitest suite bunx vitest run src/path/to/test.ts # Single test file @@ -75,7 +76,7 @@ When writing a new `useEffect`, add a brief comment explaining why an alternativ **Key patterns:** - Context provider chain defined in `App.tsx` — read that file for the full order - Vault data flows through `useYearn` context → filtered/sorted via hooks in `@shared/hooks/` -- Dual server: Vite (3000) proxies `/api/*` to Bun API server (3001). In prod, `api/` runs as Vercel serverless functions. +- Dual server: `bun run dev` starts Vite plus a Bun API server and keeps `/api/*` proxied to the selected API port. In prod, `api/` runs as Vercel serverless functions. ## Multi-Chain diff --git a/api/README.md b/api/README.md new file mode 100644 index 000000000..9c42c111a --- /dev/null +++ b/api/README.md @@ -0,0 +1,128 @@ +# API Routes + +This directory contains Vercel API functions plus `api/server.ts`, a local Bun server that mirrors the app-facing API routes on `localhost:3001`. + +## Local Server + +Run the local API with: + +```bash +bun api/server.ts +``` + +`API_PORT` overrides the default `3001`. `API_SERVER_PORT` is still accepted as a backwards-compatible fallback. + +`bun run dev:server` runs the same server with file watching. The Vite dev and preview scripts proxy `/api/*` to this server, using `API_PORT`, `VITE_API_PORT`, or `API_SERVER_PORT` when a non-default port is configured. + +The local server adds CORS to all handled routes and includes dev-only Tenderly admin routes. Vercel production routes are implemented as individual files under `api/`. + +## Route Inventory + +| Route | Method | Runtime | Purpose | +|-------|--------|---------|---------| +| `/api/holdings/history` | `GET` | Vercel + local | Daily holdings chart, USD or ETH-denominated | +| `/api/holdings/progress` | `GET` | Vercel + local | Durable progress state for long holdings requests | +| `/api/holdings/breakdown` | `GET` | Vercel + local | Per-vault breakdown for a settled UTC day | +| `/api/holdings/activity` | `GET` | Vercel + local | Recent classified vault activity | +| `/api/holdings/activity-facets` | `GET` | Vercel + local | Chain facets for holdings activity filters | +| `/api/holdings/protocol-return/history` | `GET` | Vercel + local | Protocol-return history for vault exposure | +| `/api/holdings/pnl/simple-history` | `GET` | Vercel + local | Compatibility alias for protocol-return history | +| `/api/admin/invalidate-cache` | `POST` | Vercel + local | Lazy vault cache invalidation, admin-protected | +| `/api/enso/status` | `GET` | Vercel + local | Returns whether `ENSO_API_KEY` is configured | +| `/api/enso/balances` | `GET` | Vercel + local | Proxies Enso wallet balances | +| `/api/enso/route` | `GET` | Vercel + local | Proxies Enso route quotes/transactions | +| `/api/optimization/change` | `GET` | Vercel + local | Latest or historical optimization payloads from Redis | +| `/api/optimization/alignment` | `GET` | Vercel + local | Envio keeper-event alignment for a selected optimization | +| `/api/optimization/vault-state` | `POST` | Vercel + local | Live vault and strategy debt state from chain RPCs | +| `/api/yvusd/aprs` | `GET` | Vercel + local | Proxies the yvUSD APR service | +| `/api/vault/meta` | `GET` | Vercel | Serves SPA HTML with vault-specific SEO and OG tags | +| `/api/tenderly/status` | `GET` | local only | Returns configured local Tenderly chains | +| `/api/tenderly/snapshot` | `POST` | local only | Creates a Tenderly EVM snapshot | +| `/api/tenderly/revert` | `POST` | local only | Reverts a Tenderly EVM snapshot | +| `/api/tenderly/increase-time` | `POST` | local only | Advances Tenderly chain time and optionally mines | +| `/api/tenderly/fund` | `POST` | local only | Funds native or ERC-20 balances on Tenderly | + +## Holdings APIs + +The holdings implementation is the largest API surface here. See [`lib/holdings/README.md`](./lib/holdings/README.md) for: + +- Endpoint query params and response shapes. +- Envio, Kong, yearn-prices, and DefiLlama data flow. +- `timeframe=all` support from `2024-01-01`. +- Cache schema, hashed user cache keys, and invalidation behavior. +- Historical price provider switching and yearn-prices range requests. + +## Enso Proxies + +`/api/enso/*` routes keep `ENSO_API_KEY` server-side and forward requests to `https://api.enso.finance`. + +- `/api/enso/status` returns `{ "configured": boolean }`; the Vercel handler does not currently enforce the HTTP method. +- `/api/enso/balances` requires `eoaAddress`; Vercel always requests `chainId=all`, while the local server also accepts an optional `chainId`. +- `/api/enso/route` requires `fromAddress`, `chainId`, `tokenIn`, `tokenOut`, and `amountIn`. Optional params are `slippage`, `routingStrategy`, `destinationChainId`, and `receiver`. +- `/api/enso/balances` sets `Cache-Control: private, no-store, max-age=0, must-revalidate`. + +## Optimization APIs + +The optimization routes expose the current DOA optimization payloads and the local verification data used by optimization UI flows. + +- `/api/optimization/change` reads optimization records from Upstash Redis keys under `doa:optimizations:*`. `vault=0x...` selects one vault, and `history=1` or `history=true` returns all records for that vault instead of the latest one. +- `/api/optimization/alignment` requires `vault=0x...`, selects the matching optimization, resolves its source chain, and fetches aligned keeper `DebtUpdated` events from Envio. It needs `ENVIO_GRAPHQL_URL`; `ENVIO_PASSWORD` is sent as a bearer token when configured. +- `/api/optimization/vault-state` accepts `POST` JSON shaped as `{ "vault": "0x...", "chainId": 1, "strategies": ["0x..."] }`, then reads live vault and strategy debt state from configured public RPC endpoints. +- Cache headers: `change` uses `public, s-maxage=600, stale-while-revalidate=60`; `alignment` and `vault-state` use `public, s-maxage=60, stale-while-revalidate=30`. + +## yvUSD APR Proxy + +`/api/yvusd/aprs` forwards query params to `YVUSD_APR_SERVICE_API`. + +- Default upstream: `https://yearn-yvusd-apr-service.vercel.app/api/aprs`. +- Cache header: `public, s-maxage=30, stale-while-revalidate=120`. + +## Vault Metadata HTML + +`/api/vault/meta?chainId=1&address=0x...` validates both params, loads `dist/index.html`, injects vault-specific SEO and Open Graph tags, and returns HTML. + +- `chainId` must be digits only. +- `address` must be a 20-byte EVM address. +- CDN cache header: `Vercel-CDN-Cache-Control: public, s-maxage=86400, stale-while-revalidate=604800`. + +## Tenderly Local Routes + +Tenderly admin routes only exist in `api/server.ts` and are blocked unless the request comes from localhost. + +Required env for a configured chain: + +- `VITE_TENDERLY_MODE=true`. +- `VITE_TENDERLY_CHAIN_ID_FOR_`. +- `VITE_TENDERLY_RPC_URI_FOR_`. +- `TENDERLY_ADMIN_RPC_URI_FOR_` for snapshot, revert, time travel, and funding actions. + +## Environment Variables + +| Variable | Used by | Description | +|----------|---------|-------------| +| `API_PORT` | local server, Vite proxy | Local Bun API port, default `3001` | +| `API_SERVER_PORT` | local server, Vite proxy | Backwards-compatible local API port fallback | +| `VITE_API_PORT` | Vite proxy | Client-dev API proxy port fallback | +| `API_PROXY_TARGET` / `VITE_API_PROXY_TARGET` | Vite proxy | Explicit `/api` proxy target, overrides host/port resolution | +| `API_PROXY_HOST` | Vite proxy | Host used when Vite builds the default `/api` proxy target | +| `ENSO_API_KEY` | Enso routes | Bearer token for Enso upstream requests | +| `YVUSD_APR_SERVICE_API` | yvUSD route | Upstream APR service URL | +| `ENVIO_GRAPHQL_URL` | holdings, optimization alignment | Envio GraphQL endpoint | +| `ENVIO_PASSWORD` | holdings, optimization alignment | Optional Envio secret or bearer token | +| `VITE_RPC_URI_FOR_` | holdings activity | Optional chain RPC URL for receipt enrichment | +| `HOLDINGS_PRICE_PROVIDER` | holdings | `auto`, `yearn-prices`, or `defillama` | +| `YEARN_PRICES_BASE_URL` | holdings | yearn-prices base URL | +| `YEARN_PRICES_API_URL` | holdings | Legacy alias for `YEARN_PRICES_BASE_URL` | +| `YEARN_PRICES_API_KEY` | holdings | Bearer token for yearn-prices | +| `API_KEY_PORTFOLIO` | holdings | Fallback bearer token for yearn-prices | +| `DEFILLAMA_API_KEY` | holdings | Enables DefiLlama Pro | +| `ADMIN_SECRET` | holdings admin | Required for `/api/admin/invalidate-cache` | +| `UPSTASH_REDIS_REST_URL_PORTFOLIO` | holdings | Upstash Redis REST URL for holdings cache/progress/rate limits | +| `UPSTASH_REDIS_REST_TOKEN_PORTFOLIO` | holdings | Upstash Redis REST token for holdings storage | +| `UPSTASH_REDIS_REST_URL` | optimization | Upstash Redis REST URL for optimization payloads | +| `UPSTASH_REDIS_REST_TOKEN` | optimization | Upstash Redis REST token for optimization payloads | +| `HOLDINGS_DEBUG` | local holdings | Enables holdings debug logs in `api/server.ts` | +| `VITE_TENDERLY_MODE` | local Tenderly | Enables Tenderly config parsing | +| `VITE_TENDERLY_CHAIN_ID_FOR_` | local Tenderly | Tenderly execution chain ID for a canonical chain | +| `VITE_TENDERLY_RPC_URI_FOR_` | local Tenderly | Public Tenderly RPC URI | +| `TENDERLY_ADMIN_RPC_URI_FOR_` | local Tenderly | Admin Tenderly RPC URI | diff --git a/api/admin/invalidate-cache.ts b/api/admin/invalidate-cache.ts new file mode 100644 index 000000000..7590ff3bc --- /dev/null +++ b/api/admin/invalidate-cache.ts @@ -0,0 +1,97 @@ +import type { VercelRequest, VercelResponse } from '@vercel/node' +import { ensureHoldingsStorageInitialized, isHoldingsStorageEnabled } from '../lib/holdings' +import { invalidateVaults, type VaultIdentifier } from '../lib/holdings/services/cache' + +function isValidAddress(address: string): boolean { + return /^0x[a-fA-F0-9]{40}$/.test(address) +} + +interface InvalidateRequestBody { + vaults: Array<{ address: string; chainId: number }> +} + +function validateBody(body: unknown): body is InvalidateRequestBody { + if (!body || typeof body !== 'object') return false + const b = body as Record + if (!Array.isArray(b.vaults)) return false + if (b.vaults.length === 0) return false + + for (const vault of b.vaults) { + if (!vault || typeof vault !== 'object') return false + const v = vault as Record + if (typeof v.address !== 'string' || !isValidAddress(v.address)) return false + if (typeof v.chainId !== 'number' || !Number.isInteger(v.chainId)) return false + } + + return true +} + +export default async function handler(req: VercelRequest, res: VercelResponse) { + res.setHeader('Access-Control-Allow-Origin', '*') + res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS') + res.setHeader('Access-Control-Allow-Headers', 'Content-Type, x-admin-secret') + + if (req.method === 'OPTIONS') { + return res.status(204).end() + } + + if (req.method !== 'POST') { + return res.status(405).json({ error: 'Method not allowed' }) + } + + // Check admin secret + const adminSecret = process.env.ADMIN_SECRET + if (!adminSecret) { + return res.status(503).json({ error: 'Admin endpoint not configured' }) + } + + const providedSecret = req.headers['x-admin-secret'] + if (providedSecret !== adminSecret) { + return res.status(401).json({ error: 'Unauthorized' }) + } + + // Check Redis storage is enabled + if (!isHoldingsStorageEnabled()) { + return res + .status(503) + .json({ error: 'Caching not enabled (UPSTASH_REDIS_REST_URL_PORTFOLIO/TOKEN_PORTFOLIO not configured)' }) + } + + // Validate request body + const body = req.body + if (!validateBody(body)) { + return res.status(400).json({ + error: 'Invalid request body', + expected: { + vaults: [{ address: '0x...', chainId: 1 }] + } + }) + } + + try { + await ensureHoldingsStorageInitialized() + if (!isHoldingsStorageEnabled()) { + return res + .status(503) + .json({ error: 'Caching not enabled (UPSTASH_REDIS_REST_URL_PORTFOLIO/TOKEN_PORTFOLIO not configured)' }) + } + + const vaults: VaultIdentifier[] = body.vaults.map((v) => ({ + address: v.address, + chainId: v.chainId + })) + + const invalidatedCount = await invalidateVaults(vaults) + const timestamp = new Date().toISOString() + + return res.status(200).json({ + success: true, + invalidated: invalidatedCount, + vaults: vaults.map((v) => `${v.chainId}:${v.address.toLowerCase()}`), + timestamp + }) + } catch (error) { + console.error('[Admin] Invalidate cache error:', error) + return res.status(500).json({ error: 'Failed to invalidate cache' }) + } +} diff --git a/api/enso/balances.ts b/api/enso/balances.ts index 19f8dbc1b..d7800428a 100644 --- a/api/enso/balances.ts +++ b/api/enso/balances.ts @@ -1,16 +1,26 @@ import type { VercelRequest, VercelResponse } from '@vercel/node' +import { ENSO_BALANCES_CACHE_CONTROL } from './cache' +import { checkEnsoRateLimit, isAbortError, withEnsoTimeout } from './guard' +import { validateEnsoBalancesQuery } from './validation' const ENSO_API_BASE = 'https://api.enso.finance' +const ENSO_BALANCES_RATE_LIMIT = 20 +const ENSO_BALANCES_TIMEOUT_MS = 6_000 export default async function handler(req: VercelRequest, res: VercelResponse) { if (req.method !== 'GET') { return res.status(405).json({ error: 'Method not allowed' }) } - const { eoaAddress } = req.query + const rateLimit = checkEnsoRateLimit(req, 'balances', ENSO_BALANCES_RATE_LIMIT) + if (!rateLimit.allowed) { + res.setHeader('Retry-After', String(rateLimit.retryAfter)) + return res.status(429).json({ error: 'Too many Enso balance requests' }) + } - if (!eoaAddress || typeof eoaAddress !== 'string') { - return res.status(400).json({ error: 'Missing or invalid eoaAddress' }) + const validated = validateEnsoBalancesQuery(req.query) + if (!validated.ok) { + return res.status(400).json({ error: validated.error }) } const apiKey = process.env.ENSO_API_KEY @@ -20,6 +30,7 @@ export default async function handler(req: VercelRequest, res: VercelResponse) { } try { + const { eoaAddress } = validated.value const params = new URLSearchParams({ eoaAddress, useEoa: 'true', @@ -28,27 +39,42 @@ export default async function handler(req: VercelRequest, res: VercelResponse) { const url = `${ENSO_API_BASE}/api/v1/wallet/balances?${params}` - const response = await fetch(url, { - headers: { - Authorization: `Bearer ${apiKey}` + const result = await withEnsoTimeout(ENSO_BALANCES_TIMEOUT_MS, async (signal) => { + const response = await fetch(url, { + headers: { + Authorization: `Bearer ${apiKey}` + }, + signal + }) + + if (!response.ok) { + const errorText = await response.text() + + return { errorText, response } } + + const data = await response.json() + + return { data, response } }) - if (!response.ok) { - const errorText = await response.text() - console.error(`Enso API error: ${response.status}`, errorText) - return res.status(response.status).json({ + if (!result.response.ok) { + const errorText = 'errorText' in result ? result.errorText : '' + console.error(`Enso API error: ${result.response.status}`, errorText) + return res.status(result.response.status).json({ error: 'Enso API error', - status: response.status, + status: result.response.status, details: errorText }) } - const data = await response.json() - - res.setHeader('Cache-Control', 'public, s-maxage=60, stale-while-revalidate=300') - return res.status(200).json(data) + res.setHeader('Cache-Control', ENSO_BALANCES_CACHE_CONTROL) + return res.status(200).json(result.data) } catch (error) { + if (isAbortError(error)) { + return res.status(504).json({ error: 'Enso balances request timed out' }) + } + console.error('Error proxying Enso request:', error) return res.status(500).json({ error: 'Internal server error' }) } diff --git a/api/enso/cache.test.ts b/api/enso/cache.test.ts new file mode 100644 index 000000000..e3d53f1f4 --- /dev/null +++ b/api/enso/cache.test.ts @@ -0,0 +1,8 @@ +import { describe, expect, it } from 'vitest' +import { ENSO_BALANCES_CACHE_CONTROL } from './cache' + +describe('ENSO_BALANCES_CACHE_CONTROL', () => { + it('disables intermediary and browser caching for wallet balance responses', () => { + expect(ENSO_BALANCES_CACHE_CONTROL).toBe('private, no-store, max-age=0, must-revalidate') + }) +}) diff --git a/api/enso/cache.ts b/api/enso/cache.ts new file mode 100644 index 000000000..3ec29330e --- /dev/null +++ b/api/enso/cache.ts @@ -0,0 +1 @@ +export const ENSO_BALANCES_CACHE_CONTROL = 'private, no-store, max-age=0, must-revalidate' diff --git a/api/enso/guard.ts b/api/enso/guard.ts new file mode 100644 index 000000000..6180385db --- /dev/null +++ b/api/enso/guard.ts @@ -0,0 +1,73 @@ +import type { VercelRequest } from '@vercel/node' + +type TRateLimitBucket = { + count: number + resetAt: number +} + +type TRateLimitResult = { + allowed: boolean + retryAfter: number +} + +const WINDOW_MS = 60_000 +const buckets = new Map() + +function getHeaderValue(req: VercelRequest, header: string): string | undefined { + const value = req.headers[header] + return Array.isArray(value) ? value[0] : value +} + +export function getEnsoClientKey(req: VercelRequest): string { + const forwardedFor = getHeaderValue(req, 'x-forwarded-for') + const forwardedClient = forwardedFor?.split(',')[0]?.trim() + const realIp = getHeaderValue(req, 'x-real-ip')?.trim() + const vercelIp = getHeaderValue(req, 'x-vercel-forwarded-for')?.split(',')[0]?.trim() + + return forwardedClient || realIp || vercelIp || 'unknown' +} + +export function checkEnsoRateLimit(req: VercelRequest, route: string, limit: number): TRateLimitResult { + const now = Date.now() + const clientKey = getEnsoClientKey(req) + const bucketKey = `${route}:${clientKey}` + const bucket = buckets.get(bucketKey) + const activeBucket = bucket && bucket.resetAt > now ? bucket : { count: 0, resetAt: now + WINDOW_MS } + const nextCount = activeBucket.count + 1 + + buckets.set(bucketKey, { count: nextCount, resetAt: activeBucket.resetAt }) + + if (nextCount <= limit) { + return { allowed: true, retryAfter: 0 } + } + + return { + allowed: false, + retryAfter: Math.max(1, Math.ceil((activeBucket.resetAt - now) / 1000)) + } +} + +export function resetEnsoRateLimitForTests() { + buckets.clear() +} + +export async function withEnsoTimeout( + timeoutMs: number, + operation: (signal: AbortSignal) => Promise +): Promise { + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), timeoutMs) + + try { + return await operation(controller.signal) + } finally { + clearTimeout(timeout) + } +} + +export function isAbortError(error: unknown): boolean { + const isDomAbortError = + typeof DOMException === 'function' && error instanceof DOMException && error.name === 'AbortError' + + return isDomAbortError || (error instanceof Error && error.name === 'AbortError') +} diff --git a/api/enso/route.test.ts b/api/enso/route.test.ts new file mode 100644 index 000000000..8a425a3bb --- /dev/null +++ b/api/enso/route.test.ts @@ -0,0 +1,92 @@ +import type { VercelRequest, VercelResponse } from '@vercel/node' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import handler from './route' + +const ORIGINAL_ENSO_API_KEY = process.env.ENSO_API_KEY + +const validRoutePayload = { + tx: { + to: '0x0000000000000000000000000000000000000001', + data: '0x1234', + value: '0', + from: '0x0000000000000000000000000000000000000002', + chainId: 1 + }, + amountOut: '100', + minAmountOut: '95', + gas: '123456', + route: [] +} + +type MockResponse = { + body?: unknown + json: (body: unknown) => MockResponse + status: (statusCode: number) => MockResponse + statusCode?: number +} + +function createMockRequest(): VercelRequest { + return { + method: 'GET', + query: { + fromAddress: '0x0000000000000000000000000000000000000002', + chainId: '1', + tokenIn: '0x0000000000000000000000000000000000000003', + tokenOut: '0x0000000000000000000000000000000000000004', + amountIn: '100' + } + } as unknown as VercelRequest +} + +function createMockResponse(): MockResponse { + const response: MockResponse = { + body: undefined, + json: vi.fn((body: unknown): MockResponse => { + response.body = body + return response + }), + status: vi.fn((statusCode: number): MockResponse => { + response.statusCode = statusCode + return response + }), + statusCode: undefined + } + + return response +} + +describe('/api/enso/route', () => { + beforeEach(() => { + process.env.ENSO_API_KEY = 'test-key' + }) + + afterEach(() => { + if (ORIGINAL_ENSO_API_KEY === undefined) { + delete process.env.ENSO_API_KEY + } else { + process.env.ENSO_API_KEY = ORIGINAL_ENSO_API_KEY + } + + vi.restoreAllMocks() + }) + + it('returns a non-2xx error instead of a route body for malformed successful quote fields', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + json: async () => ({ ...validRoutePayload, amountOut: '1e3' }), + ok: true, + status: 200 + } as Response) + + const response = createMockResponse() + + await handler(createMockRequest(), response as unknown as VercelResponse) + + expect(response.statusCode).toBe(502) + expect(response.body).toMatchObject({ + error: 'EnsoRouteError', + message: 'Unable to find route', + statusCode: 502 + }) + expect((response.body as { amountOut?: unknown }).amountOut).toBeUndefined() + }) +}) diff --git a/api/enso/route.ts b/api/enso/route.ts index 9709811f9..39618f693 100644 --- a/api/enso/route.ts +++ b/api/enso/route.ts @@ -1,28 +1,26 @@ import type { VercelRequest, VercelResponse } from '@vercel/node' +import { normalizeEnsoRouteResponse } from '../../src/components/pages/vaults/hooks/solvers/ensoRoute' +import { checkEnsoRateLimit, isAbortError, withEnsoTimeout } from './guard' +import { validateEnsoQuoteQuery } from './validation' const ENSO_API_BASE = 'https://api.enso.finance' +const ENSO_ROUTE_RATE_LIMIT = 30 +const ENSO_ROUTE_TIMEOUT_MS = 8_000 export default async function handler(req: VercelRequest, res: VercelResponse) { if (req.method !== 'GET') { return res.status(405).json({ error: 'Method not allowed' }) } - const { fromAddress, chainId, tokenIn, tokenOut, amountIn, slippage, destinationChainId, receiver } = req.query - - if (!fromAddress || typeof fromAddress !== 'string') { - return res.status(400).json({ error: 'Missing or invalid fromAddress' }) - } - if (!chainId || typeof chainId !== 'string') { - return res.status(400).json({ error: 'Missing or invalid chainId' }) - } - if (!tokenIn || typeof tokenIn !== 'string') { - return res.status(400).json({ error: 'Missing or invalid tokenIn' }) + const rateLimit = checkEnsoRateLimit(req, 'route', ENSO_ROUTE_RATE_LIMIT) + if (!rateLimit.allowed) { + res.setHeader('Retry-After', String(rateLimit.retryAfter)) + return res.status(429).json({ error: 'Too many Enso route requests' }) } - if (!tokenOut || typeof tokenOut !== 'string') { - return res.status(400).json({ error: 'Missing or invalid tokenOut' }) - } - if (!amountIn || typeof amountIn !== 'string') { - return res.status(400).json({ error: 'Missing or invalid amountIn' }) + + const validated = validateEnsoQuoteQuery(req.query) + if (!validated.ok) { + return res.status(400).json({ error: validated.error }) } const apiKey = process.env.ENSO_API_KEY @@ -32,39 +30,72 @@ export default async function handler(req: VercelRequest, res: VercelResponse) { } try { + const { + fromAddress, + chainId, + tokenIn, + tokenOut, + amountIn, + slippage, + routingStrategy, + destinationChainId, + receiver + } = validated.value const params = new URLSearchParams({ fromAddress, chainId, tokenIn, tokenOut, amountIn, - slippage: (slippage as string) || '100' + slippage }) - if (destinationChainId && typeof destinationChainId === 'string') { + if (destinationChainId) { params.set('destinationChainId', destinationChainId) } - if (receiver && typeof receiver === 'string') { + if (receiver) { params.set('receiver', receiver) } + if (routingStrategy) { + params.set('routingStrategy', routingStrategy) + } const url = `${ENSO_API_BASE}/api/v1/shortcuts/route?${params}` - const response = await fetch(url, { - headers: { - Authorization: `Bearer ${apiKey}`, - 'Content-Type': 'application/json' - } - }) + const { data, response } = await withEnsoTimeout(ENSO_ROUTE_TIMEOUT_MS, async (signal) => { + const response = await fetch(url, { + headers: { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json' + }, + signal + }) + const data = await response.json() - const data = await response.json() + return { data, response } + }) if (!response.ok) { return res.status(response.status).json(data) } - return res.status(200).json(data) + const parsedChainId = Number(chainId) + const normalizedResponse = normalizeEnsoRouteResponse( + data, + response.status, + Number.isFinite(parsedChainId) ? parsedChainId : undefined + ) + + if (normalizedResponse.error) { + return res.status(normalizedResponse.error.statusCode).json(normalizedResponse.error) + } + + return res.status(200).json(normalizedResponse.route) } catch (error) { + if (isAbortError(error)) { + return res.status(504).json({ error: 'Enso route request timed out' }) + } + console.error('Error proxying Enso route request:', error) return res.status(500).json({ error: 'Internal server error' }) } diff --git a/api/enso/routes.test.ts b/api/enso/routes.test.ts new file mode 100644 index 000000000..8320864af --- /dev/null +++ b/api/enso/routes.test.ts @@ -0,0 +1,290 @@ +import type { VercelRequest } from '@vercel/node' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import balancesHandler from './balances' +import { resetEnsoRateLimitForTests } from './guard' +import routeHandler from './route' + +const ADDRESS_A = '0x1111111111111111111111111111111111111111' +const ADDRESS_B = '0x2222222222222222222222222222222222222222' +const ADDRESS_C = '0x3333333333333333333333333333333333333333' + +type TMockResponse = { + statusCode: number + headers: Record + body: unknown + setHeader: (name: string, value: string) => void + status: (code: number) => TMockResponse + json: (payload: unknown) => TMockResponse +} + +function createMockResponse(): TMockResponse { + return { + statusCode: 200, + headers: {}, + body: null, + setHeader(name: string, value: string) { + this.headers[name] = value + }, + status(code: number) { + this.statusCode = code + return this + }, + json(payload: unknown) { + this.body = payload + return this + } + } +} + +function quoteRequest(query: Record = {}, ip = '203.0.113.10'): VercelRequest { + return { + method: 'GET', + headers: { + 'x-forwarded-for': `${ip}, 10.0.0.1` + }, + query: { + fromAddress: ADDRESS_A, + chainId: '1', + tokenIn: ADDRESS_B, + tokenOut: ADDRESS_C, + amountIn: '1000000000000000000', + ...query + } + } as VercelRequest +} + +function balancesRequest(query: Record = {}, ip = '203.0.113.20'): VercelRequest { + return { + method: 'GET', + headers: { + 'x-forwarded-for': ip + }, + query: { + eoaAddress: ADDRESS_A, + ...query + } + } as VercelRequest +} + +function okJson(payload: unknown): Response { + return new Response(JSON.stringify(payload), { + status: 200, + headers: { 'Content-Type': 'application/json' } + }) +} + +function rejectOnAbort(signal: AbortSignal | null | undefined): Promise { + return new Promise((_resolve, reject) => { + signal?.addEventListener('abort', () => reject(new DOMException('Aborted', 'AbortError'))) + }) +} + +describe('Enso API proxy guards', () => { + beforeEach(() => { + resetEnsoRateLimitForTests() + process.env.ENSO_API_KEY = 'test-enso-key' + vi.stubGlobal( + 'fetch', + vi.fn().mockImplementation(() => Promise.resolve(okJson({ ok: true }))) + ) + }) + + afterEach(() => { + vi.unstubAllGlobals() + vi.useRealTimers() + delete process.env.ENSO_API_KEY + }) + + it('forwards valid quote requests with the server-side API key and default slippage', async () => { + const res = createMockResponse() + + await routeHandler(quoteRequest(), res as any) + + expect(res.statusCode).toBe(200) + expect(res.body).toEqual({ ok: true }) + expect(fetch).toHaveBeenCalledTimes(1) + + const [url, init] = vi.mocked(fetch).mock.calls[0] + const requestUrl = new URL(String(url)) + + expect(requestUrl.searchParams.get('fromAddress')).toBe(ADDRESS_A) + expect(requestUrl.searchParams.get('chainId')).toBe('1') + expect(requestUrl.searchParams.get('amountIn')).toBe('1000000000000000000') + expect(requestUrl.searchParams.get('slippage')).toBe('100') + expect(init?.headers).toMatchObject({ + Authorization: 'Bearer test-enso-key', + 'Content-Type': 'application/json' + }) + }) + + it('forwards valid balance requests with the server-side API key and existing parameters', async () => { + const res = createMockResponse() + + await balancesHandler(balancesRequest(), res as any) + + expect(res.statusCode).toBe(200) + expect(fetch).toHaveBeenCalledTimes(1) + + const [url, init] = vi.mocked(fetch).mock.calls[0] + const requestUrl = new URL(String(url)) + + expect(requestUrl.searchParams.get('eoaAddress')).toBe(ADDRESS_A) + expect(requestUrl.searchParams.get('useEoa')).toBe('true') + expect(requestUrl.searchParams.get('chainId')).toBe('all') + expect(init?.headers).toMatchObject({ + Authorization: 'Bearer test-enso-key' + }) + }) + + it('rate limits quote requests per forwarded client IP while allowing a different IP bucket', async () => { + const responses = await Promise.all( + Array.from({ length: 31 }, async () => { + const res = createMockResponse() + await routeHandler(quoteRequest({}, '198.51.100.1'), res as any) + return res + }) + ) + const otherIpResponse = createMockResponse() + + await routeHandler(quoteRequest({}, '198.51.100.2'), otherIpResponse as any) + + expect(responses.at(-1)?.statusCode).toBe(429) + expect(responses.at(-1)?.body).toEqual({ error: 'Too many Enso route requests' }) + expect(otherIpResponse.statusCode).toBe(200) + expect(fetch).toHaveBeenCalledTimes(31) + }) + + it('rate limits balance requests per forwarded client IP', async () => { + const responses = await Promise.all( + Array.from({ length: 21 }, async () => { + const res = createMockResponse() + await balancesHandler(balancesRequest({}, '198.51.100.3'), res as any) + return res + }) + ) + + expect(responses.at(-1)?.statusCode).toBe(429) + expect(responses.at(-1)?.body).toEqual({ error: 'Too many Enso balance requests' }) + expect(fetch).toHaveBeenCalledTimes(20) + }) + + it('rejects malformed quote inputs before calling upstream', async () => { + const invalidCases = [ + { fromAddress: '0xbad' }, + { chainId: 'mainnet' }, + { amountIn: '-1' }, + { amountIn: '1'.repeat(81) }, + { slippage: '1001' } + ] + + const responses = await Promise.all( + invalidCases.map(async (query, index) => { + const res = createMockResponse() + await routeHandler(quoteRequest(query, `203.0.113.${30 + index}`), res as any) + return res + }) + ) + + expect(responses.map((res) => res.statusCode)).toEqual([400, 400, 400, 400, 400]) + expect(fetch).not.toHaveBeenCalled() + }) + + it('rejects malformed balance addresses before calling upstream', async () => { + const res = createMockResponse() + + await balancesHandler(balancesRequest({ eoaAddress: '0xbad' }), res as any) + + expect(res.statusCode).toBe(400) + expect(fetch).not.toHaveBeenCalled() + }) + + it('maps quote upstream aborts to HTTP 504 without leaking upstream details', async () => { + vi.useFakeTimers() + vi.stubGlobal( + 'fetch', + vi.fn( + (_url: string | URL | Request, init?: RequestInit) => + new Promise((_resolve, reject) => { + init?.signal?.addEventListener('abort', () => reject(new DOMException('Aborted', 'AbortError'))) + }) + ) + ) + + const res = createMockResponse() + const request = routeHandler(quoteRequest(), res as any) + + await vi.advanceTimersByTimeAsync(8_000) + await request + + expect(res.statusCode).toBe(504) + expect(res.body).toEqual({ error: 'Enso route request timed out' }) + }) + + it('keeps the quote timeout active while reading the upstream body', async () => { + vi.useFakeTimers() + vi.stubGlobal( + 'fetch', + vi.fn((_url: string | URL | Request, init?: RequestInit) => + Promise.resolve({ + ok: true, + status: 200, + json: () => rejectOnAbort(init?.signal) + } as Response) + ) + ) + + const res = createMockResponse() + const request = routeHandler(quoteRequest(), res as any) + + await vi.advanceTimersByTimeAsync(8_000) + await request + + expect(res.statusCode).toBe(504) + expect(res.body).toEqual({ error: 'Enso route request timed out' }) + }) + + it('maps balance upstream aborts to HTTP 504 without leaking upstream details', async () => { + vi.useFakeTimers() + vi.stubGlobal( + 'fetch', + vi.fn( + (_url: string | URL | Request, init?: RequestInit) => + new Promise((_resolve, reject) => { + init?.signal?.addEventListener('abort', () => reject(new DOMException('Aborted', 'AbortError'))) + }) + ) + ) + + const res = createMockResponse() + const request = balancesHandler(balancesRequest(), res as any) + + await vi.advanceTimersByTimeAsync(6_000) + await request + + expect(res.statusCode).toBe(504) + expect(res.body).toEqual({ error: 'Enso balances request timed out' }) + }) + + it('keeps the balance timeout active while reading the upstream body', async () => { + vi.useFakeTimers() + vi.stubGlobal( + 'fetch', + vi.fn((_url: string | URL | Request, init?: RequestInit) => + Promise.resolve({ + ok: true, + status: 200, + json: () => rejectOnAbort(init?.signal) + } as Response) + ) + ) + + const res = createMockResponse() + const request = balancesHandler(balancesRequest(), res as any) + + await vi.advanceTimersByTimeAsync(6_000) + await request + + expect(res.statusCode).toBe(504) + expect(res.body).toEqual({ error: 'Enso balances request timed out' }) + }) +}) diff --git a/api/enso/validation.ts b/api/enso/validation.ts new file mode 100644 index 000000000..b8cdef2aa --- /dev/null +++ b/api/enso/validation.ts @@ -0,0 +1,114 @@ +const ADDRESS_PATTERN = /^0x[a-fA-F0-9]{40}$/ +const DECIMAL_PATTERN = /^(?:0|[1-9]\d*)(?:\.\d+)?$/ +const INTEGER_PATTERN = /^(?:0|[1-9]\d*)$/ +const MAX_AMOUNT_LENGTH = 80 +const MAX_OPTIONAL_VALUE_LENGTH = 64 + +export type TQuoteParams = { + fromAddress: string + chainId: string + tokenIn: string + tokenOut: string + amountIn: string + slippage: string + destinationChainId?: string + receiver?: string + routingStrategy?: string +} + +type TValidationResult = { ok: true; value: T } | { ok: false; error: string } + +function singleValue(value: unknown): string | undefined { + return typeof value === 'string' ? value.trim() : undefined +} + +function isValidAddress(value: string | undefined): value is string { + return Boolean(value && ADDRESS_PATTERN.test(value)) +} + +function isValidChainId(value: string | undefined): value is string { + return Boolean(value && INTEGER_PATTERN.test(value) && Number(value) > 0 && Number.isSafeInteger(Number(value))) +} + +function isPositiveDecimal(value: string | undefined): value is string { + return Boolean( + value && + value.length <= MAX_AMOUNT_LENGTH && + DECIMAL_PATTERN.test(value) && + Number(value) > 0 && + Number.isFinite(Number(value)) + ) +} + +function isValidSlippage(value: string | undefined): value is string { + return Boolean(value && INTEGER_PATTERN.test(value) && Number(value) >= 1 && Number(value) <= 1000) +} + +function isValidRoutingStrategy(value: string | undefined): value is string { + return Boolean(value && value.length <= MAX_OPTIONAL_VALUE_LENGTH && /^[a-zA-Z0-9_-]+$/.test(value)) +} + +export function validateEnsoQuoteQuery(query: Record): TValidationResult { + const fromAddress = singleValue(query.fromAddress) + const chainId = singleValue(query.chainId) + const tokenIn = singleValue(query.tokenIn) + const tokenOut = singleValue(query.tokenOut) + const amountIn = singleValue(query.amountIn) + const slippage = singleValue(query.slippage) || '100' + const destinationChainId = singleValue(query.destinationChainId) + const receiver = singleValue(query.receiver) + const routingStrategy = singleValue(query.routingStrategy) + + if (!isValidAddress(fromAddress)) { + return { ok: false, error: 'Missing or invalid fromAddress' } + } + if (!isValidChainId(chainId)) { + return { ok: false, error: 'Missing or invalid chainId' } + } + if (!isValidAddress(tokenIn)) { + return { ok: false, error: 'Missing or invalid tokenIn' } + } + if (!isValidAddress(tokenOut)) { + return { ok: false, error: 'Missing or invalid tokenOut' } + } + if (!isPositiveDecimal(amountIn)) { + return { ok: false, error: 'Missing or invalid amountIn' } + } + if (!isValidSlippage(slippage)) { + return { ok: false, error: 'Missing or invalid slippage' } + } + if (destinationChainId && !isValidChainId(destinationChainId)) { + return { ok: false, error: 'Missing or invalid destinationChainId' } + } + if (receiver && !isValidAddress(receiver)) { + return { ok: false, error: 'Missing or invalid receiver' } + } + if (routingStrategy && !isValidRoutingStrategy(routingStrategy)) { + return { ok: false, error: 'Missing or invalid routingStrategy' } + } + + return { + ok: true, + value: { + fromAddress, + chainId, + tokenIn, + tokenOut, + amountIn, + slippage, + ...(destinationChainId ? { destinationChainId } : {}), + ...(receiver ? { receiver } : {}), + ...(routingStrategy ? { routingStrategy } : {}) + } + } +} + +export function validateEnsoBalancesQuery(query: Record): TValidationResult<{ eoaAddress: string }> { + const eoaAddress = singleValue(query.eoaAddress) + + if (!isValidAddress(eoaAddress)) { + return { ok: false, error: 'Missing or invalid eoaAddress' } + } + + return { ok: true, value: { eoaAddress } } +} diff --git a/api/holdings/activity-facets.ts b/api/holdings/activity-facets.ts new file mode 100644 index 000000000..0156f10cc --- /dev/null +++ b/api/holdings/activity-facets.ts @@ -0,0 +1,144 @@ +import type { VercelRequest, VercelResponse } from '@vercel/node' +import type { VaultVersion } from '../lib/holdings' +import { checkRateLimit, ensureHoldingsStorageInitialized } from '../lib/holdings' + +function simpleHash(str: string): string { + const hash = Array.from(str).reduce((currentHash, char) => { + const nextHash = (currentHash << 5) - currentHash + char.charCodeAt(0) + return nextHash & nextHash + }, 0) + return Math.abs(hash).toString(36) +} + +function getClientIdentifier(req: VercelRequest): string { + const forwarded = req.headers['x-forwarded-for'] + if (forwarded) { + return (Array.isArray(forwarded) ? forwarded[0] : forwarded).split(',')[0].trim() + } + + const ua = req.headers['user-agent'] || '' + const lang = req.headers['accept-language'] || '' + const encoding = req.headers['accept-encoding'] || '' + return `fp-${simpleHash(ua + lang + encoding)}` +} + +function isValidAddress(address: string): boolean { + return /^0x[a-fA-F0-9]{40}$/.test(address) +} + +function parseVersion(value: string | string[] | undefined): VaultVersion { + return value === 'v2' || value === 'v3' ? value : 'all' +} + +function parsePositiveInteger(value: string | string[] | undefined, fallback: number, max: number): number { + const rawValue = Array.isArray(value) ? value[0] : value + const parsedValue = Number(rawValue) + + return Number.isInteger(parsedValue) && parsedValue > 0 ? Math.min(parsedValue, max) : fallback +} + +function parseNonNegativeInteger(value: string | string[] | undefined): number { + const rawValue = Array.isArray(value) ? value[0] : value + const parsedValue = Number(rawValue) + + return Number.isInteger(parsedValue) && parsedValue >= 0 ? parsedValue : 0 +} + +export default async function handler(req: VercelRequest, res: VercelResponse) { + res.setHeader('Access-Control-Allow-Origin', '*') + res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS') + res.setHeader('Access-Control-Allow-Headers', 'Content-Type') + + if (req.method === 'OPTIONS') { + return res.status(204).end() + } + + if (req.method !== 'GET') { + return res.status(405).json({ error: 'Method not allowed' }) + } + + try { + await ensureHoldingsStorageInitialized() + } catch (error) { + console.error('Holdings activity facets storage initialization error:', error) + return res.status(500).json({ error: 'Failed to initialize holdings storage' }) + } + + const clientId = getClientIdentifier(req) + const rateCheck = await checkRateLimit(clientId) + if (!rateCheck.allowed) { + res.setHeader('Retry-After', String(rateCheck.retryAfter)) + return res.status(429).json({ error: 'Too many requests', retryAfter: rateCheck.retryAfter }) + } + + const envioUrl = process.env.ENVIO_GRAPHQL_URL + if (!envioUrl) { + return res.status(503).json({ + error: 'Holdings activity API not configured', + details: 'ENVIO_GRAPHQL_URL environment variable is not set. This feature requires a running Envio indexer.' + }) + } + + const { + address, + version: versionParam, + limitPerSource: limitPerSourceParam, + offsetPerSource: offsetPerSourceParam + } = req.query + + if (!address || typeof address !== 'string') { + return res.status(400).json({ error: 'Missing required parameter: address' }) + } + + if (!isValidAddress(address)) { + return res.status(400).json({ error: 'Invalid Ethereum address' }) + } + + try { + const { fetchRecentAddressScopedActivityEvents } = await import('../lib/holdings') + const version = parseVersion(versionParam) + const limitPerSource = parsePositiveInteger(limitPerSourceParam, 250, 1000) + const offsetPerSource = parseNonNegativeInteger(offsetPerSourceParam) + const events = await fetchRecentAddressScopedActivityEvents( + address, + version, + limitPerSource, + undefined, + offsetPerSource + ) + const hasMore = + events.hasMoreDeposits || events.hasMoreWithdrawals || events.hasMoreTransfersIn || events.hasMoreTransfersOut + const chainIds = Array.from( + new Set( + [...events.deposits, ...events.withdrawals, ...events.transfersIn, ...events.transfersOut].map( + (event) => event.chainId + ) + ) + ).sort((firstChainId, secondChainId) => firstChainId - secondChainId) + + res.setHeader('Cache-Control', 'public, s-maxage=300, stale-while-revalidate=900') + return res.status(200).json({ + address: address.toLowerCase(), + version, + facets: { chainIds }, + pageInfo: { + hasMore, + nextOffsetPerSource: hasMore ? offsetPerSource + limitPerSource : null + } + }) + } catch (error) { + console.error('Holdings activity facets error:', error) + + if (process.env.NODE_ENV === 'development') { + const message = error instanceof Error ? error.message : String(error) + const stack = error instanceof Error ? error.stack : undefined + return res.status(502).json({ + error: 'Failed to fetch holdings activity facets', + message, + stack + }) + } + + return res.status(502).json({ error: 'Failed to fetch holdings activity facets' }) + } +} diff --git a/api/holdings/activity.test.ts b/api/holdings/activity.test.ts new file mode 100644 index 000000000..d521ac7e8 --- /dev/null +++ b/api/holdings/activity.test.ts @@ -0,0 +1,309 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const TEST_ADDRESS = '0x2222222222222222222222222222222222222222' + +const ensureHoldingsStorageInitializedMock = vi.fn() +const checkRateLimitMock = vi.fn() +const getHoldingsActivityMock = vi.fn() + +vi.mock('../lib/holdings', () => ({ + ensureHoldingsStorageInitialized: ensureHoldingsStorageInitializedMock, + checkRateLimit: checkRateLimitMock, + getHoldingsActivity: getHoldingsActivityMock +})) + +type TMockResponse = { + statusCode: number + headers: Record + body: unknown + setHeader: (name: string, value: string) => void + status: (code: number) => TMockResponse + json: (payload: unknown) => TMockResponse + end: () => TMockResponse +} + +function createMockResponse(): TMockResponse { + return { + statusCode: 200, + headers: {}, + body: null, + setHeader(name: string, value: string) { + this.headers[name] = value + }, + status(code: number) { + this.statusCode = code + return this + }, + json(payload: unknown) { + this.body = payload + return this + }, + end() { + return this + } + } +} + +describe('holdings activity route', () => { + beforeEach(() => { + vi.resetModules() + vi.clearAllMocks() + ensureHoldingsStorageInitializedMock.mockResolvedValue(undefined) + checkRateLimitMock.mockResolvedValue({ allowed: true, retryAfter: 0 }) + process.env.ENVIO_GRAPHQL_URL = 'https://envio.example/graphql' + }) + + it('returns indexed activity entries for a wallet', async () => { + getHoldingsActivityMock.mockResolvedValue({ + address: TEST_ADDRESS, + version: 'all', + limit: 10, + offset: 0, + pageInfo: { + hasMore: true, + nextOffset: 10 + }, + entries: [ + { + chainId: 1, + txHash: '0xabc', + timestamp: 1776902400, + action: 'deposit', + transferDirection: null, + vaultAddress: '0xbe53a109b494e5c9f97b9cd39fe969be68bf6204', + familyVaultAddress: '0xbe53a109b494e5c9f97b9cd39fe969be68bf6204', + assetSymbol: 'USDC', + assetAmount: '1000000', + assetAmountFormatted: 1, + shareAmount: '1000000000000000000', + shareAmountFormatted: 1, + status: 'ok' + } + ] + }) + + const { default: handler } = await import('./activity') + const req = { + method: 'GET', + query: { + address: TEST_ADDRESS, + limit: '10', + offset: '0' + }, + headers: {} + } as any + const res = createMockResponse() + + await handler(req, res as any) + + expect(getHoldingsActivityMock).toHaveBeenCalledWith( + TEST_ADDRESS, + 'all', + 10, + 0, + { + type: 'all', + chainId: null, + startTimestamp: null, + endTimestamp: null + }, + false + ) + expect(res.statusCode).toBe(200) + expect(res.body).toEqual({ + address: TEST_ADDRESS, + version: 'all', + limit: 10, + offset: 0, + pageInfo: { + hasMore: true, + nextOffset: 10 + }, + entries: [ + { + chainId: 1, + txHash: '0xabc', + timestamp: 1776902400, + action: 'deposit', + transferDirection: null, + vaultAddress: '0xbe53a109b494e5c9f97b9cd39fe969be68bf6204', + familyVaultAddress: '0xbe53a109b494e5c9f97b9cd39fe969be68bf6204', + assetSymbol: 'USDC', + assetAmount: '1000000', + assetAmountFormatted: 1, + shareAmount: '1000000000000000000', + shareAmountFormatted: 1, + status: 'ok' + } + ] + }) + }) + + it('returns an empty collection when no indexed activity exists', async () => { + getHoldingsActivityMock.mockResolvedValue({ + address: TEST_ADDRESS, + version: 'all', + limit: 10, + offset: 0, + pageInfo: { + hasMore: false, + nextOffset: null + }, + entries: [] + }) + + const { default: handler } = await import('./activity') + const req = { + method: 'GET', + query: { + address: TEST_ADDRESS + }, + headers: {} + } as any + const res = createMockResponse() + + await handler(req, res as any) + + expect(res.statusCode).toBe(200) + expect(res.body).toEqual({ + address: TEST_ADDRESS, + version: 'all', + limit: 10, + offset: 0, + pageInfo: { + hasMore: false, + nextOffset: null + }, + entries: [] + }) + }) + + it('passes through valid activity filters', async () => { + getHoldingsActivityMock.mockResolvedValue({ + address: TEST_ADDRESS, + version: 'all', + limit: 10, + offset: 0, + pageInfo: { + hasMore: false, + nextOffset: null + }, + entries: [] + }) + + const { default: handler } = await import('./activity') + const req = { + method: 'GET', + query: { + address: TEST_ADDRESS, + type: 'withdraw', + chainId: '137', + startTimestamp: '1776729600', + endTimestamp: '1777334399' + }, + headers: {} + } as any + const res = createMockResponse() + + await handler(req, res as any) + + expect(getHoldingsActivityMock).toHaveBeenCalledWith( + TEST_ADDRESS, + 'all', + 10, + 0, + { + type: 'withdraw', + chainId: 137, + startTimestamp: 1776729600, + endTimestamp: 1777334399 + }, + false + ) + expect(res.statusCode).toBe(200) + }) + + it('passes through the transfer activity filter', async () => { + getHoldingsActivityMock.mockResolvedValue({ + address: TEST_ADDRESS, + version: 'all', + limit: 10, + offset: 0, + pageInfo: { + hasMore: false, + nextOffset: null + }, + entries: [] + }) + + const { default: handler } = await import('./activity') + const req = { + method: 'GET', + query: { + address: TEST_ADDRESS, + type: 'transfer' + }, + headers: {} + } as any + const res = createMockResponse() + + await handler(req, res as any) + + expect(getHoldingsActivityMock).toHaveBeenCalledWith( + TEST_ADDRESS, + 'all', + 10, + 0, + { + type: 'transfer', + chainId: null, + startTimestamp: null, + endTimestamp: null + }, + false + ) + expect(res.statusCode).toBe(200) + }) + + it('passes through the swap activity filter', async () => { + getHoldingsActivityMock.mockResolvedValue({ + address: TEST_ADDRESS, + version: 'all', + limit: 10, + offset: 0, + pageInfo: { + hasMore: false, + nextOffset: null + }, + entries: [] + }) + + const { default: handler } = await import('./activity') + const req = { + method: 'GET', + query: { + address: TEST_ADDRESS, + type: 'swap' + }, + headers: {} + } as any + const res = createMockResponse() + + await handler(req, res as any) + + expect(getHoldingsActivityMock).toHaveBeenCalledWith( + TEST_ADDRESS, + 'all', + 10, + 0, + { + type: 'swap', + chainId: null, + startTimestamp: null, + endTimestamp: null + }, + false + ) + expect(res.statusCode).toBe(200) + }) +}) diff --git a/api/holdings/activity.ts b/api/holdings/activity.ts new file mode 100644 index 000000000..689a89089 --- /dev/null +++ b/api/holdings/activity.ts @@ -0,0 +1,180 @@ +import type { VercelRequest, VercelResponse } from '@vercel/node' +import type { HoldingsActivityTypeFilter, VaultVersion } from '../lib/holdings' +import { checkRateLimit, ensureHoldingsStorageInitialized } from '../lib/holdings' + +function simpleHash(str: string): string { + const hash = Array.from(str).reduce((currentHash, char) => { + const nextHash = (currentHash << 5) - currentHash + char.charCodeAt(0) + return nextHash & nextHash + }, 0) + return Math.abs(hash).toString(36) +} + +function getClientIdentifier(req: VercelRequest): string { + const forwarded = req.headers['x-forwarded-for'] + if (forwarded) { + return (Array.isArray(forwarded) ? forwarded[0] : forwarded).split(',')[0].trim() + } + + const ua = req.headers['user-agent'] || '' + const lang = req.headers['accept-language'] || '' + const encoding = req.headers['accept-encoding'] || '' + return `fp-${simpleHash(ua + lang + encoding)}` +} + +function isValidAddress(address: string): boolean { + return /^0x[a-fA-F0-9]{40}$/.test(address) +} + +function parseVersion(value: string | string[] | undefined): VaultVersion { + return value === 'v2' || value === 'v3' ? value : 'all' +} + +function parseLimit(value: string | string[] | undefined): number { + const rawValue = Array.isArray(value) ? value[0] : value + const parsedValue = Number(rawValue) + + if (!Number.isFinite(parsedValue) || !Number.isInteger(parsedValue)) { + return 10 + } + + return Math.min(Math.max(parsedValue, 1), 50) +} + +function parseOffset(value: string | string[] | undefined): number { + const rawValue = Array.isArray(value) ? value[0] : value + const parsedValue = Number(rawValue) + + if (!Number.isFinite(parsedValue) || !Number.isInteger(parsedValue)) { + return 0 + } + + return Math.max(parsedValue, 0) +} + +function parseType(value: string | string[] | undefined): HoldingsActivityTypeFilter { + const rawValue = Array.isArray(value) ? value[0] : value + + return rawValue === 'deposit' || + rawValue === 'withdraw' || + rawValue === 'stake' || + rawValue === 'unstake' || + rawValue === 'transfer' || + rawValue === 'swap' + ? rawValue + : 'all' +} + +function parseChainId(value: string | string[] | undefined): number | null { + const rawValue = Array.isArray(value) ? value[0] : value + const parsedValue = Number(rawValue) + + return Number.isInteger(parsedValue) && parsedValue > 0 ? parsedValue : null +} + +function parseTimestamp(value: string | string[] | undefined): number | null { + const rawValue = Array.isArray(value) ? value[0] : value + if (!rawValue) { + return null + } + + const parsedValue = Number(rawValue) + + return Number.isInteger(parsedValue) && parsedValue >= 0 ? parsedValue : null +} + +function parseBoolean(value: string | string[] | undefined): boolean { + const rawValue = Array.isArray(value) ? value[0] : value + + return rawValue === 'true' || rawValue === '1' +} + +export default async function handler(req: VercelRequest, res: VercelResponse) { + res.setHeader('Access-Control-Allow-Origin', '*') + res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS') + res.setHeader('Access-Control-Allow-Headers', 'Content-Type') + + if (req.method === 'OPTIONS') { + return res.status(204).end() + } + + if (req.method !== 'GET') { + return res.status(405).json({ error: 'Method not allowed' }) + } + + try { + await ensureHoldingsStorageInitialized() + } catch (error) { + console.error('Holdings activity storage initialization error:', error) + return res.status(500).json({ error: 'Failed to initialize holdings storage' }) + } + + const clientId = getClientIdentifier(req) + const rateCheck = await checkRateLimit(clientId) + if (!rateCheck.allowed) { + res.setHeader('Retry-After', String(rateCheck.retryAfter)) + return res.status(429).json({ error: 'Too many requests', retryAfter: rateCheck.retryAfter }) + } + + const envioUrl = process.env.ENVIO_GRAPHQL_URL + if (!envioUrl) { + return res.status(503).json({ + error: 'Holdings activity API not configured', + details: 'ENVIO_GRAPHQL_URL environment variable is not set. This feature requires a running Envio indexer.' + }) + } + + const { + address, + version: versionParam, + limit: limitParam, + offset: offsetParam, + type: typeParam, + chainId: chainIdParam, + startTimestamp: startTimestampParam, + endTimestamp: endTimestampParam, + includeFacets: includeFacetsParam + } = req.query + + if (!address || typeof address !== 'string') { + return res.status(400).json({ error: 'Missing required parameter: address' }) + } + + if (!isValidAddress(address)) { + return res.status(400).json({ error: 'Invalid Ethereum address' }) + } + + try { + const { getHoldingsActivity } = await import('../lib/holdings') + const activity = await getHoldingsActivity( + address, + parseVersion(versionParam), + parseLimit(limitParam), + parseOffset(offsetParam), + { + type: parseType(typeParam), + chainId: parseChainId(chainIdParam), + startTimestamp: parseTimestamp(startTimestampParam), + endTimestamp: parseTimestamp(endTimestampParam) + }, + parseBoolean(includeFacetsParam) + ) + + res.setHeader('Cache-Control', 'public, s-maxage=60, stale-while-revalidate=300') + return res.status(200).json(activity) + } catch (error) { + console.error('Holdings activity error:', error) + + if (process.env.NODE_ENV === 'development') { + const message = error instanceof Error ? error.message : String(error) + const stack = error instanceof Error ? error.stack : undefined + return res.status(502).json({ + error: 'Failed to fetch holdings activity', + message, + stack + }) + } + + return res.status(502).json({ error: 'Failed to fetch holdings activity' }) + } +} diff --git a/api/holdings/breakdown.test.ts b/api/holdings/breakdown.test.ts new file mode 100644 index 000000000..39b313f84 --- /dev/null +++ b/api/holdings/breakdown.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from 'vitest' +import { parseUtcDateParam } from './breakdown' + +describe('parseUtcDateParam', () => { + it('parses valid UTC dates', () => { + expect(parseUtcDateParam('2026-02-28')).toBe(Math.floor(Date.UTC(2026, 1, 28) / 1000)) + }) + + it('rejects impossible calendar dates instead of normalizing them', () => { + expect(parseUtcDateParam('2026-02-31')).toBeNull() + expect(parseUtcDateParam('2026-13-01')).toBeNull() + expect(parseUtcDateParam('2026-00-10')).toBeNull() + }) +}) diff --git a/api/holdings/breakdown.ts b/api/holdings/breakdown.ts new file mode 100644 index 000000000..a501550a8 --- /dev/null +++ b/api/holdings/breakdown.ts @@ -0,0 +1,154 @@ +import type { VercelRequest, VercelResponse } from '@vercel/node' +import type { HoldingsEventFetchType, HoldingsEventPaginationMode, VaultVersion } from '../lib/holdings' +import { checkRateLimit, ensureHoldingsStorageInitialized } from '../lib/holdings' + +function simpleHash(str: string): string { + let hash = 0 + for (let i = 0; i < str.length; i++) { + const char = str.charCodeAt(i) + hash = (hash << 5) - hash + char + hash = hash & hash + } + return Math.abs(hash).toString(36) +} + +function getClientIdentifier(req: VercelRequest): string { + const forwarded = req.headers['x-forwarded-for'] + if (forwarded) { + return (Array.isArray(forwarded) ? forwarded[0] : forwarded).split(',')[0].trim() + } + + const ua = req.headers['user-agent'] || '' + const lang = req.headers['accept-language'] || '' + const encoding = req.headers['accept-encoding'] || '' + return `fp-${simpleHash(ua + lang + encoding)}` +} + +function isValidAddress(address: string): boolean { + return /^0x[a-fA-F0-9]{40}$/.test(address) +} + +function parseHoldingsEventFetchType(value: string | string[] | undefined): HoldingsEventFetchType { + return value === 'parallel' ? 'parallel' : 'seq' +} + +function parseHoldingsEventPaginationMode(value: string | string[] | undefined): HoldingsEventPaginationMode { + return value === 'all' ? 'all' : 'paged' +} + +export function parseUtcDateParam(value: string | string[] | undefined): number | null { + if (!value || Array.isArray(value)) { + return null + } + + const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value) + if (!match) { + return null + } + + const [, year, month, day] = match + const yearNumber = Number(year) + const monthNumber = Number(month) + const dayNumber = Number(day) + const utcDate = new Date(Date.UTC(yearNumber, monthNumber - 1, dayNumber)) + + if ( + utcDate.getUTCFullYear() !== yearNumber || + utcDate.getUTCMonth() !== monthNumber - 1 || + utcDate.getUTCDate() !== dayNumber + ) { + return null + } + + const timestamp = Math.floor(utcDate.getTime() / 1000) + return Number.isFinite(timestamp) ? timestamp : null +} + +export default async function handler(req: VercelRequest, res: VercelResponse) { + res.setHeader('Access-Control-Allow-Origin', '*') + res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS') + res.setHeader('Access-Control-Allow-Headers', 'Content-Type') + + if (req.method === 'OPTIONS') { + return res.status(204).end() + } + + if (req.method !== 'GET') { + return res.status(405).json({ error: 'Method not allowed' }) + } + + try { + await ensureHoldingsStorageInitialized() + } catch (error) { + console.error('Holdings breakdown storage initialization error:', error) + return res.status(500).json({ error: 'Failed to initialize holdings storage' }) + } + + const clientId = getClientIdentifier(req) + const rateCheck = await checkRateLimit(clientId) + if (!rateCheck.allowed) { + res.setHeader('Retry-After', String(rateCheck.retryAfter)) + return res.status(429).json({ error: 'Too many requests', retryAfter: rateCheck.retryAfter }) + } + + const envioUrl = process.env.ENVIO_GRAPHQL_URL + if (!envioUrl) { + return res.status(503).json({ + error: 'Holdings breakdown API not configured', + details: 'ENVIO_GRAPHQL_URL environment variable is not set. This feature requires a running Envio indexer.' + }) + } + + const { + address, + date: dateParam, + version: versionParam, + fetchType: fetchTypeParam, + paginationMode: paginationModeParam + } = req.query + + if (!address || typeof address !== 'string') { + return res.status(400).json({ error: 'Missing required parameter: address' }) + } + + if (!isValidAddress(address)) { + return res.status(400).json({ error: 'Invalid Ethereum address' }) + } + + const breakdownTimestamp = parseUtcDateParam(dateParam) + if (dateParam && breakdownTimestamp === null) { + return res.status(400).json({ error: 'Invalid date format, expected YYYY-MM-DD' }) + } + + const version: VaultVersion = versionParam === 'v2' || versionParam === 'v3' ? versionParam : 'all' + const fetchType = parseHoldingsEventFetchType(fetchTypeParam) + const paginationMode = parseHoldingsEventPaginationMode(paginationModeParam) + + try { + const { getHoldingsBreakdown } = await import('../lib/holdings') + const breakdown = await getHoldingsBreakdown( + address, + version, + fetchType, + paginationMode, + breakdownTimestamp ?? undefined + ) + + res.setHeader('Cache-Control', 'public, s-maxage=300, stale-while-revalidate=600') + return res.status(200).json(breakdown) + } catch (error) { + console.error('Holdings breakdown error:', error) + + if (process.env.NODE_ENV === 'development') { + const message = error instanceof Error ? error.message : String(error) + const stack = error instanceof Error ? error.stack : undefined + return res.status(502).json({ + error: 'Failed to fetch holdings breakdown', + message, + stack + }) + } + + return res.status(502).json({ error: 'Failed to fetch holdings breakdown' }) + } +} diff --git a/api/holdings/history.test.ts b/api/holdings/history.test.ts new file mode 100644 index 000000000..be25c7bfa --- /dev/null +++ b/api/holdings/history.test.ts @@ -0,0 +1,130 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const ensureHoldingsStorageInitializedMock = vi.fn() +const checkRateLimitMock = vi.fn() +const getHistoricalHoldingsChartMock = vi.fn() +const TEST_WALLET_ADDRESS = process.env.HOLDINGS_TEST_WALLET_ADDRESS ?? '0x1111111111111111111111111111111111111111' + +vi.mock('../lib/holdings', () => ({ + ensureHoldingsStorageInitialized: ensureHoldingsStorageInitializedMock, + checkRateLimit: checkRateLimitMock, + getHistoricalHoldingsChart: getHistoricalHoldingsChartMock +})) + +type TMockResponse = { + statusCode: number + headers: Record + body: unknown + setHeader: (name: string, value: string) => void + status: (code: number) => TMockResponse + json: (payload: unknown) => TMockResponse + end: () => TMockResponse +} + +function createMockResponse(): TMockResponse { + return { + statusCode: 200, + headers: {}, + body: null, + setHeader(name: string, value: string) { + this.headers[name] = value + }, + status(code: number) { + this.statusCode = code + return this + }, + json(payload: unknown) { + this.body = payload + return this + }, + end() { + return this + } + } +} + +describe('holdings history route', () => { + beforeEach(() => { + vi.resetModules() + vi.clearAllMocks() + ensureHoldingsStorageInitializedMock.mockResolvedValue(undefined) + checkRateLimitMock.mockResolvedValue({ allowed: true, retryAfter: 0 }) + process.env.ENVIO_GRAPHQL_URL = 'https://envio.example/graphql' + }) + + it('returns zero-filled settled history for wallets that only have same-day activity', async () => { + getHistoricalHoldingsChartMock.mockResolvedValue({ + address: TEST_WALLET_ADDRESS, + periodDays: 365, + timeframe: '1y', + denomination: 'usd', + hasActivity: true, + dataPoints: [ + { date: '2026-04-20', timestamp: 1776729599, value: 0 }, + { date: '2026-04-21', timestamp: 1776815999, value: 0 } + ] + }) + + const { default: handler } = await import('./history') + const req = { + method: 'GET', + query: { + address: TEST_WALLET_ADDRESS + }, + headers: {} + } as any + const res = createMockResponse() + + await handler(req, res as any) + + expect(res.statusCode).toBe(200) + expect(res.body).toEqual({ + address: TEST_WALLET_ADDRESS, + version: 'all', + denomination: 'usd', + timeframe: '1y', + dataPoints: [ + { date: '2026-04-20', value: 0 }, + { date: '2026-04-21', value: 0 } + ] + }) + }) + + it('passes multi-vault filters to historical holdings chart', async () => { + getHistoricalHoldingsChartMock.mockResolvedValue({ + address: TEST_WALLET_ADDRESS, + periodDays: 365, + timeframe: '1y', + denomination: 'usd', + hasActivity: true, + dataPoints: [{ date: '2026-04-21', timestamp: 1776815999, value: 42 }] + }) + + const { default: handler } = await import('./history') + const req = { + method: 'GET', + query: { + address: TEST_WALLET_ADDRESS, + vaults: '1:0x696d02Db93291651ED510704c9b286841d506987,1:0xAaaFEa48472f77563961Cdb53291DEDfB46F9040' + }, + headers: {} + } as any + const res = createMockResponse() + + await handler(req, res as any) + + expect(res.statusCode).toBe(200) + expect(getHistoricalHoldingsChartMock).toHaveBeenCalledWith( + TEST_WALLET_ADDRESS, + 'all', + 'seq', + 'paged', + 'usd', + '1y', + [ + { chainId: 1, vaultAddress: '0x696d02Db93291651ED510704c9b286841d506987' }, + { chainId: 1, vaultAddress: '0xAaaFEa48472f77563961Cdb53291DEDfB46F9040' } + ] + ) + }) +}) diff --git a/api/holdings/history.ts b/api/holdings/history.ts new file mode 100644 index 000000000..ed7273ce3 --- /dev/null +++ b/api/holdings/history.ts @@ -0,0 +1,296 @@ +import type { VercelRequest, VercelResponse } from '@vercel/node' +import type { + HoldingsEventFetchType, + HoldingsEventPaginationMode, + HoldingsHistoryDenomination, + HoldingsHistoryTimeframe, + VaultVersion +} from '../lib/holdings' +import { checkRateLimit, ensureHoldingsStorageInitialized } from '../lib/holdings' +import { + createHoldingsDebugContext, + debugError, + debugLog, + isHoldingsDebugRequested, + withHoldingsDebugContext +} from '../lib/holdings/services/debug' +import { startHoldingsProgress, updateHoldingsProgress } from '../lib/holdings/services/progress' + +function simpleHash(str: string): string { + let hash = 0 + for (let i = 0; i < str.length; i++) { + const char = str.charCodeAt(i) + hash = (hash << 5) - hash + char + hash = hash & hash + } + return Math.abs(hash).toString(36) +} + +function getClientIdentifier(req: VercelRequest): string { + const forwarded = req.headers['x-forwarded-for'] + if (forwarded) { + return (Array.isArray(forwarded) ? forwarded[0] : forwarded).split(',')[0].trim() + } + + // Fallback: fingerprint from headers + const ua = req.headers['user-agent'] || '' + const lang = req.headers['accept-language'] || '' + const encoding = req.headers['accept-encoding'] || '' + return `fp-${simpleHash(ua + lang + encoding)}` +} + +function isValidAddress(address: string): boolean { + return /^0x[a-fA-F0-9]{40}$/.test(address) +} + +function parseVaultFilters({ + vault, + chainId, + vaults +}: { + vault: string | string[] | undefined + chainId: string | string[] | undefined + vaults: string | string[] | undefined +}): Array<{ chainId: number; vaultAddress: string }> | null | undefined { + if (vaults !== undefined) { + if (typeof vaults !== 'string') { + return null + } + + const entries = vaults + .split(',') + .map((entry) => entry.trim()) + .filter(Boolean) + const parsedEntries = entries.map((entry) => { + const [entryChainId, entryVaultAddress] = entry.split(':') + const parsedChainId = Number(entryChainId) + + if ( + !entryChainId || + !entryVaultAddress || + !Number.isInteger(parsedChainId) || + !isValidAddress(entryVaultAddress) + ) { + return null + } + + return { chainId: parsedChainId, vaultAddress: entryVaultAddress } + }) + + if (parsedEntries.some((entry) => entry === null)) { + return null + } + + return parsedEntries.filter((entry): entry is { chainId: number; vaultAddress: string } => entry !== null) + } + + if (vault === undefined) { + return undefined + } + + if (typeof vault !== 'string' || !isValidAddress(vault)) { + return null + } + + if (!chainId || typeof chainId !== 'string' || !Number.isInteger(Number(chainId))) { + return null + } + + return [{ chainId: Number(chainId), vaultAddress: vault }] +} + +function parseHoldingsEventFetchType(value: string | string[] | undefined): HoldingsEventFetchType { + return value === 'parallel' ? 'parallel' : 'seq' +} + +function parseHoldingsEventPaginationMode(value: string | string[] | undefined): HoldingsEventPaginationMode { + return value === 'all' ? 'all' : 'paged' +} + +function parseHoldingsHistoryDenomination(value: string | string[] | undefined): HoldingsHistoryDenomination { + return value === 'eth' ? 'eth' : 'usd' +} + +function parseHoldingsHistoryTimeframe(value: string | string[] | undefined): HoldingsHistoryTimeframe { + return value === 'all' ? 'all' : '1y' +} + +export default async function handler(req: VercelRequest, res: VercelResponse) { + // CORS headers + res.setHeader('Access-Control-Allow-Origin', '*') + res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS') + res.setHeader('Access-Control-Allow-Headers', 'Content-Type') + + if (req.method === 'OPTIONS') { + return res.status(204).end() + } + + if (req.method !== 'GET') { + return res.status(405).json({ error: 'Method not allowed' }) + } + + try { + await ensureHoldingsStorageInitialized() + } catch (error) { + console.error('Holdings history storage initialization error:', error) + return res.status(500).json({ error: 'Failed to initialize holdings storage' }) + } + + // Rate limiting + const clientId = getClientIdentifier(req) + const rateCheck = await checkRateLimit(clientId) + if (!rateCheck.allowed) { + res.setHeader('Retry-After', String(rateCheck.retryAfter)) + return res.status(429).json({ error: 'Too many requests', retryAfter: rateCheck.retryAfter }) + } + + // Check if Envio is configured + const envioUrl = process.env.ENVIO_GRAPHQL_URL + if (!envioUrl) { + return res.status(503).json({ + error: 'Holdings history API not configured', + details: 'ENVIO_GRAPHQL_URL environment variable is not set. This feature requires a running Envio indexer.' + }) + } + + const { + address, + chainId: chainIdParam, + vault: vaultParam, + vaults: vaultsParam, + version: versionParam, + fetchType: fetchTypeParam, + paginationMode: paginationModeParam, + denomination: denominationParam, + timeframe: timeframeParam, + debug: debugParam, + progressId: progressIdParam + } = req.query + + if (!address || typeof address !== 'string') { + return res.status(400).json({ error: 'Missing required parameter: address' }) + } + + if (!isValidAddress(address)) { + return res.status(400).json({ error: 'Invalid Ethereum address' }) + } + + const vaultFilters = parseVaultFilters({ + vault: vaultParam, + chainId: chainIdParam, + vaults: vaultsParam + }) + + if (vaultFilters === null) { + return res.status(400).json({ error: 'Invalid vault filter' }) + } + + const version: VaultVersion = versionParam === 'v2' || versionParam === 'v3' ? versionParam : 'all' + const fetchType = parseHoldingsEventFetchType(fetchTypeParam) + const paginationMode = parseHoldingsEventPaginationMode(paginationModeParam) + const denomination = parseHoldingsHistoryDenomination(denominationParam) + const timeframe = parseHoldingsHistoryTimeframe(timeframeParam) + const progressId = typeof progressIdParam === 'string' ? progressIdParam : null + const debugEnabled = isHoldingsDebugRequested(typeof debugParam === 'string' ? debugParam : null) + + try { + const activeProgressId = await startHoldingsProgress({ + id: progressId, + route: 'history', + address, + message: 'Fetching historical user data' + }) + await updateHoldingsProgress(activeProgressId, { + progress: 8, + message: 'Fetching historical user data', + detail: null + }) + const { getHistoricalHoldingsChart } = await import('../lib/holdings') + const holdings = await withHoldingsDebugContext( + createHoldingsDebugContext('history', address, debugEnabled, { + progressId: activeProgressId + }), + async () => { + debugLog('route', 'started holdings history request', { + version, + fetchType, + paginationMode, + denomination, + timeframe + }) + + try { + const response = await getHistoricalHoldingsChart( + address, + version, + fetchType, + paginationMode, + denomination, + timeframe, + vaultFilters + ) + debugLog('route', 'completed holdings history request', { + version, + fetchType, + paginationMode, + denomination, + timeframe, + points: response.dataPoints.length + }) + return response + } catch (error) { + debugError('route', 'holdings history request failed', error, { version, fetchType, paginationMode }) + throw error + } + } + ) + + if (!holdings.hasActivity) { + await updateHoldingsProgress(activeProgressId, { + status: 'complete', + progress: 100, + message: 'No historical holdings found', + detail: null + }) + return res.status(404).json({ error: 'No holdings found for address' }) + } + + await updateHoldingsProgress(activeProgressId, { + status: 'complete', + progress: 100, + message: 'Historical user data ready', + detail: `${holdings.dataPoints.length} chart points` + }) + + res.setHeader('Cache-Control', 'public, s-maxage=300, stale-while-revalidate=600') + return res.status(200).json({ + address: holdings.address, + version, + denomination, + timeframe, + dataPoints: holdings.dataPoints.map((dp) => ({ + date: dp.date, + value: dp.value + })) + }) + } catch (error) { + await updateHoldingsProgress(progressId, { + status: 'error', + message: 'Failed to fetch historical user data', + detail: error instanceof Error ? error.message : String(error) + }) + console.error('Holdings history error:', error) + + if (process.env.NODE_ENV === 'development') { + const message = error instanceof Error ? error.message : String(error) + const stack = error instanceof Error ? error.stack : undefined + return res.status(502).json({ + error: 'Failed to fetch historical holdings', + message, + stack + }) + } + + return res.status(502).json({ error: 'Failed to fetch historical holdings' }) + } +} diff --git a/api/holdings/pnl/simple-history.test.ts b/api/holdings/pnl/simple-history.test.ts new file mode 100644 index 000000000..7747709ef --- /dev/null +++ b/api/holdings/pnl/simple-history.test.ts @@ -0,0 +1,104 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const ensureHoldingsStorageInitializedMock = vi.fn() +const checkRateLimitMock = vi.fn() +const getHoldingsProtocolReturnHistoryMock = vi.fn() +const TEST_WALLET_ADDRESS = process.env.HOLDINGS_TEST_WALLET_ADDRESS ?? '0x1111111111111111111111111111111111111111' + +vi.mock('../../lib/holdings', () => ({ + ensureHoldingsStorageInitialized: ensureHoldingsStorageInitializedMock, + checkRateLimit: checkRateLimitMock, + getHoldingsProtocolReturnHistory: getHoldingsProtocolReturnHistoryMock +})) + +type TMockResponse = { + statusCode: number + headers: Record + body: unknown + setHeader: (name: string, value: string) => void + status: (code: number) => TMockResponse + json: (payload: unknown) => TMockResponse + end: () => TMockResponse +} + +function createMockResponse(): TMockResponse { + return { + statusCode: 200, + headers: {}, + body: null, + setHeader(name: string, value: string) { + this.headers[name] = value + }, + status(code: number) { + this.statusCode = code + return this + }, + json(payload: unknown) { + this.body = payload + return this + }, + end() { + return this + } + } +} + +describe('holdings simple pnl history route', () => { + beforeEach(() => { + vi.resetModules() + vi.clearAllMocks() + ensureHoldingsStorageInitializedMock.mockResolvedValue(undefined) + checkRateLimitMock.mockResolvedValue({ allowed: true, retryAfter: 0 }) + process.env.ENVIO_GRAPHQL_URL = 'https://envio.example/graphql' + }) + + it('passes multi-vault filters through the simple history alias', async () => { + getHoldingsProtocolReturnHistoryMock.mockResolvedValue({ + address: TEST_WALLET_ADDRESS.toLowerCase(), + version: 'all', + timeframe: '1y', + generatedAt: '2026-04-28T00:00:00.000Z', + summary: { + totalVaults: 2, + completeVaults: 2, + partialVaults: 0, + recommendedGrowthDisplay: 'index', + recommendedGrowthDisplayReason: 'mixed', + openBaselineCompositionUsd: { + stable: 0, + ethFamily: 0, + other: 0 + }, + isComplete: true + }, + dataPoints: [], + familySeries: [] + }) + + const { default: handler } = await import('./simple-history') + const req = { + method: 'GET', + query: { + address: TEST_WALLET_ADDRESS, + vaults: '1:0x696d02Db93291651ED510704c9b286841d506987,1:0xAaaFEa48472f77563961Cdb53291DEDfB46F9040' + }, + headers: {} + } as any + const res = createMockResponse() + + await handler(req, res as any) + + expect(res.statusCode).toBe(200) + expect(getHoldingsProtocolReturnHistoryMock).toHaveBeenCalledWith( + TEST_WALLET_ADDRESS, + 'all', + 'seq', + 'paged', + '1y', + [ + { chainId: 1, vaultAddress: '0x696d02Db93291651ED510704c9b286841d506987' }, + { chainId: 1, vaultAddress: '0xAaaFEa48472f77563961Cdb53291DEDfB46F9040' } + ] + ) + }) +}) diff --git a/api/holdings/pnl/simple-history.ts b/api/holdings/pnl/simple-history.ts new file mode 100644 index 000000000..b1be31ab4 --- /dev/null +++ b/api/holdings/pnl/simple-history.ts @@ -0,0 +1 @@ +export { default } from '../protocol-return/history' diff --git a/api/holdings/progress.ts b/api/holdings/progress.ts new file mode 100644 index 000000000..1c0ecc94d --- /dev/null +++ b/api/holdings/progress.ts @@ -0,0 +1,26 @@ +import type { VercelRequest, VercelResponse } from '@vercel/node' +import { getHoldingsProgress } from '../lib/holdings/services/progress' + +export default async function handler(req: VercelRequest, res: VercelResponse) { + res.setHeader('Access-Control-Allow-Origin', '*') + res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS') + res.setHeader('Access-Control-Allow-Headers', 'Content-Type') + + if (req.method === 'OPTIONS') { + return res.status(204).end() + } + + if (req.method !== 'GET') { + return res.status(405).json({ error: 'Method not allowed' }) + } + + const id = req.query.id + const progress = await getHoldingsProgress(typeof id === 'string' ? id : null) + + if (!progress) { + return res.status(404).json({ error: 'Progress not found' }) + } + + res.setHeader('Cache-Control', 'no-store') + return res.status(200).json(progress) +} diff --git a/api/holdings/protocol-return/history.ts b/api/holdings/protocol-return/history.ts new file mode 100644 index 000000000..83d6429b8 --- /dev/null +++ b/api/holdings/protocol-return/history.ts @@ -0,0 +1,277 @@ +import type { VercelRequest, VercelResponse } from '@vercel/node' +import type { + HoldingsEventFetchType, + HoldingsEventPaginationMode, + HoldingsHistoryTimeframe, + VaultVersion +} from '../../lib/holdings' +import { checkRateLimit, ensureHoldingsStorageInitialized } from '../../lib/holdings' +import { + createHoldingsDebugContext, + debugError, + debugLog, + isHoldingsDebugRequested, + withHoldingsDebugContext +} from '../../lib/holdings/services/debug' +import { startHoldingsProgress, updateHoldingsProgress } from '../../lib/holdings/services/progress' + +function simpleHash(str: string): string { + const hash = Array.from(str).reduce((currentHash, char) => { + const nextHash = (currentHash << 5) - currentHash + char.charCodeAt(0) + return nextHash & nextHash + }, 0) + return Math.abs(hash).toString(36) +} + +function getClientIdentifier(req: VercelRequest): string { + const forwarded = req.headers['x-forwarded-for'] + if (forwarded) { + return (Array.isArray(forwarded) ? forwarded[0] : forwarded).split(',')[0].trim() + } + + const ua = req.headers['user-agent'] || '' + const lang = req.headers['accept-language'] || '' + const encoding = req.headers['accept-encoding'] || '' + return `fp-${simpleHash(ua + lang + encoding)}` +} + +function isValidAddress(address: string): boolean { + return /^0x[a-fA-F0-9]{40}$/.test(address) +} + +function parseVaultFilters({ + vault, + chainId, + vaults +}: { + vault: string | string[] | undefined + chainId: string | string[] | undefined + vaults: string | string[] | undefined +}): Array<{ chainId: number; vaultAddress: string }> | null | undefined { + if (vaults !== undefined) { + if (typeof vaults !== 'string') { + return null + } + + const entries = vaults + .split(',') + .map((entry) => entry.trim()) + .filter(Boolean) + const parsedEntries = entries.map((entry) => { + const [entryChainId, entryVaultAddress] = entry.split(':') + const parsedChainId = Number(entryChainId) + + if ( + !entryChainId || + !entryVaultAddress || + !Number.isInteger(parsedChainId) || + !isValidAddress(entryVaultAddress) + ) { + return null + } + + return { chainId: parsedChainId, vaultAddress: entryVaultAddress } + }) + + if (parsedEntries.some((entry) => entry === null)) { + return null + } + + return parsedEntries.filter((entry): entry is { chainId: number; vaultAddress: string } => entry !== null) + } + + if (vault === undefined) { + return undefined + } + + if (typeof vault !== 'string' || !isValidAddress(vault)) { + return null + } + + if (!chainId || typeof chainId !== 'string' || !Number.isInteger(Number(chainId))) { + return null + } + + return [{ chainId: Number(chainId), vaultAddress: vault }] +} + +function parseHoldingsEventFetchType(value: string | string[] | undefined): HoldingsEventFetchType { + return value === 'parallel' ? 'parallel' : 'seq' +} + +function parseHoldingsEventPaginationMode(value: string | string[] | undefined): HoldingsEventPaginationMode { + return value === 'all' ? 'all' : 'paged' +} + +function parseHoldingsHistoryTimeframe(value: string | string[] | undefined): HoldingsHistoryTimeframe { + return value === 'all' ? 'all' : '1y' +} + +export default async function handler(req: VercelRequest, res: VercelResponse) { + res.setHeader('Access-Control-Allow-Origin', '*') + res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS') + res.setHeader('Access-Control-Allow-Headers', 'Content-Type') + + if (req.method === 'OPTIONS') { + return res.status(204).end() + } + + if (req.method !== 'GET') { + return res.status(405).json({ error: 'Method not allowed' }) + } + + try { + await ensureHoldingsStorageInitialized() + } catch (error) { + console.error('Holdings protocol return history storage initialization error:', error) + return res.status(500).json({ error: 'Failed to initialize holdings storage' }) + } + + const clientId = getClientIdentifier(req) + const rateCheck = await checkRateLimit(clientId) + if (!rateCheck.allowed) { + res.setHeader('Retry-After', String(rateCheck.retryAfter)) + return res.status(429).json({ error: 'Too many requests', retryAfter: rateCheck.retryAfter }) + } + + const envioUrl = process.env.ENVIO_GRAPHQL_URL + if (!envioUrl) { + return res.status(503).json({ + error: 'Holdings protocol return history API not configured', + details: 'ENVIO_GRAPHQL_URL environment variable is not set. This feature requires a running Envio indexer.' + }) + } + + const { + address, + chainId: chainIdParam, + vault: vaultParam, + vaults: vaultsParam, + version: versionParam, + fetchType: fetchTypeParam, + paginationMode: paginationModeParam, + timeframe: timeframeParam, + debug: debugParam, + progressId: progressIdParam + } = req.query + + if (!address || typeof address !== 'string') { + return res.status(400).json({ error: 'Missing required parameter: address' }) + } + + if (!isValidAddress(address)) { + return res.status(400).json({ error: 'Invalid Ethereum address' }) + } + + const vaultFilters = parseVaultFilters({ + vault: vaultParam, + chainId: chainIdParam, + vaults: vaultsParam + }) + + if (vaultFilters === null) { + return res.status(400).json({ error: 'Invalid vault filter' }) + } + + const version: VaultVersion = versionParam === 'v2' || versionParam === 'v3' ? versionParam : 'all' + const fetchType = parseHoldingsEventFetchType(fetchTypeParam) + const paginationMode = parseHoldingsEventPaginationMode(paginationModeParam) + const timeframe = parseHoldingsHistoryTimeframe(timeframeParam) + const progressId = typeof progressIdParam === 'string' ? progressIdParam : null + const debugEnabled = isHoldingsDebugRequested(typeof debugParam === 'string' ? debugParam : null) + + try { + const activeProgressId = await startHoldingsProgress({ + id: progressId, + route: 'pnl-simple-history', + address, + message: 'Fetching historical user data' + }) + await updateHoldingsProgress(activeProgressId, { + progress: 8, + message: 'Fetching historical user data', + detail: null + }) + const { getHoldingsProtocolReturnHistory } = await import('../../lib/holdings') + const history = await withHoldingsDebugContext( + createHoldingsDebugContext('protocol-return-history', address, debugEnabled, { + progressId: activeProgressId + }), + async () => { + debugLog('route', 'started holdings protocol return history request', { + version, + timeframe, + fetchType, + paginationMode + }) + + try { + const response = await getHoldingsProtocolReturnHistory( + address, + version, + fetchType, + paginationMode, + timeframe, + vaultFilters + ) + debugLog('route', 'completed holdings protocol return history request', { + version, + timeframe, + fetchType, + paginationMode, + totalVaults: response.summary.totalVaults, + points: response.dataPoints.length + }) + return response + } catch (error) { + debugError('route', 'holdings protocol return history request failed', error, { + version, + timeframe, + fetchType, + paginationMode + }) + throw error + } + } + ) + + if (history.summary.totalVaults === 0) { + await updateHoldingsProgress(activeProgressId, { + status: 'complete', + progress: 100, + message: 'No historical holdings found', + detail: null + }) + return res.status(404).json({ error: 'No holdings found for address' }) + } + + await updateHoldingsProgress(activeProgressId, { + status: 'complete', + progress: 100, + message: 'Historical user data ready', + detail: `${history.dataPoints.length} chart points` + }) + + res.setHeader('Cache-Control', 'public, s-maxage=300, stale-while-revalidate=600') + return res.status(200).json(history) + } catch (error) { + await updateHoldingsProgress(progressId, { + status: 'error', + message: 'Failed to fetch historical user data', + detail: error instanceof Error ? error.message : String(error) + }) + console.error('Holdings protocol return history error:', error) + + if (process.env.NODE_ENV === 'development') { + const message = error instanceof Error ? error.message : String(error) + const stack = error instanceof Error ? error.stack : undefined + return res.status(502).json({ + error: 'Failed to fetch holdings protocol return history', + message, + stack + }) + } + + return res.status(502).json({ error: 'Failed to fetch holdings protocol return history' }) + } +} diff --git a/api/lib/holdings/README.md b/api/lib/holdings/README.md new file mode 100644 index 000000000..31bffed19 --- /dev/null +++ b/api/lib/holdings/README.md @@ -0,0 +1,590 @@ +# Holdings APIs + +Calculates historical holdings value, per-vault breakdowns, recent activity, and protocol-return history for Yearn vault positions. + +## Runtime Shape + +``` +Frontend hooks + │ + ├─ GET /api/holdings/history + ├─ GET /api/holdings/progress + ├─ GET /api/holdings/breakdown + ├─ GET /api/holdings/activity + ├─ GET /api/holdings/activity-facets + ├─ GET /api/holdings/protocol-return/history + └─ GET /api/holdings/pnl/simple-history + │ + ▼ +Holdings services + │ + ├─ Envio GraphQL: deposits, withdrawals, transfers + ├─ Kong: vault metadata and historical PPS + ├─ yearn-prices or DefiLlama: historical token prices + └─ Upstash Redis: optional server-side cache, progress, rate limits, invalidations +``` + +In production, files under `api/` run as Vercel functions. In local development, `api/server.ts` exposes the same holdings routes on the Bun API server at `localhost:3001` and adds local-only debug and refresh controls. + +## Core Model + +### Holdings Value + +```text +USD value = vault shares * price per share * vault asset USD price +``` + +- `vault shares`: reconstructed from indexed deposits, withdrawals, and transfers. +- `price per share`: fetched from Kong historical PPS data. +- `vault asset USD price`: fetched from yearn-prices when configured, otherwise DefiLlama. + +LP and nested-vault assets are valued the same way: the vault asset token receives a USD price, then vault shares and PPS convert the user's position into that asset amount. + +### Settled Daily History + +History endpoints return settled UTC days only. The latest point is the previous settled UTC day, not an intraday moving "today" point. + +- `timeframe=1y`: last `365` settled UTC days. +- `timeframe=all`: supported range from `2024-01-01T00:00:00Z` through the latest settled UTC day. + +The API internally values each day at `23:59:59 UTC`. + +## Services + +| Service | Source | Purpose | +|---------|--------|---------| +| `graphql.ts` | Envio indexer | Fetch V2/V3 deposits, withdrawals, and transfers with paged or experimental all-at-once modes | +| `settledHoldingsContext.ts` | Local orchestration | Build reusable settled event, timeline, metadata, raw PnL, and PPS contexts | +| `vaults.ts` | Kong | Fetch global vault metadata, staking-to-family mappings, hidden flags, and snapshot fallback metadata | +| `kong.ts` | Kong | Fetch historical PPS timelines with request dedupe and retries | +| `defillama.ts` | yearn-prices / DefiLlama | Switchable historical price client with request batching and retries | +| `nestedVaultPrices.ts` | Local | Expand and derive nested vault asset pricing where a vault asset is another Yearn vault | +| `aggregator.ts` | Local | Holdings history, ETH-denominated history, and breakdown calculations | +| `activity.ts` | Local | Recent user activity classification: deposit, withdraw, stake, unstake, transfer, swap | +| `activityReceiptEnrichment.ts` | Chain RPC | Optional transaction and receipt enrichment for zaps, reward claims, and direct V2 vault actions | +| `pnlEvents.ts` | Local | Shared raw event records for protocol-return history | +| `pnlSimple.ts` | Local | Protocol-return exposure history without FIFO cost-basis accounting | +| `cache.ts` | Upstash Redis | Daily totals and lazy vault invalidation | +| `progress.ts` | Upstash Redis | Short-lived progress records and logs for long history requests | +| `ratelimit.ts` | Upstash Redis | Simple per-client request windows for public holdings routes | + +## Event Semantics + +The API supports Yearn V2 and V3 vaults. + +| Version | Deposit event | Withdraw event | User field | +|---------|---------------|----------------|------------| +| V3 | `Deposit` | `Withdraw` | `owner` | +| V2 | `V2Deposit` | `V2Withdraw` | `recipient` | + +Transfers are also indexed to account for share movement not represented by direct deposits or withdrawals. + +- Transfers in: user received vault shares. +- Transfers out: user sent vault shares. +- Mint transfers are excluded when deposit events already cover the vault. +- Burn transfers are excluded when withdraw events already cover the vault. +- Transfer-only vaults keep mint/burn transfers because there may be no indexed deposit/withdraw events. +- Staking vaults are mapped to the underlying family vault through Kong metadata and local staking mappings. +- Vaults marked `isHidden=true` in authoritative Kong metadata are excluded from holdings totals, breakdown rows, activity rows, and protocol-return history. + +## Price Provider + +`defillama.ts` is intentionally still named for compatibility, but it now selects between yearn-prices and DefiLlama. + +Provider selection: + +- `HOLDINGS_PRICE_PROVIDER=auto`: use yearn-prices when `YEARN_PRICES_API_KEY` or `API_KEY_PORTFOLIO` is present; otherwise use DefiLlama. +- `HOLDINGS_PRICE_PROVIDER=yearn-prices`: require yearn-prices credentials and fail fast if missing. +- `HOLDINGS_PRICE_PROVIDER=defillama`: force DefiLlama. + +yearn-prices behavior: + +- Base URL defaults to `https://prices.yearn.dev`. +- API key is sent as `Authorization: Bearer `. +- `YEARN_PRICES_API_KEY` has priority; `API_KEY_PORTFOLIO` is the fallback. +- Timestamps are normalized to UTC day end before the API request. +- Contiguous daily histories up to `366` days use `/api/prices/rangeHistorical`. +- Sparse or single-day lookups use `/api/prices/batchHistorical`. +- Returned UTC day-end prices are materialized back onto the originally requested timestamps for the response map. +- Prices are not read from or written to the local database. + +DefiLlama behavior: + +- Free route: `https://coins.llama.fi/batchHistorical?coins=...`. +- Pro route is used when `DEFILLAMA_API_KEY` is set: `https://pro-api.llama.fi/{key}/coins/batchHistorical?coins=...`. +- Strict timestamp mode only accepts exact or near-exact prior prices; UTC-day mode accepts prices within the day window. +- Prices and misses are not read from or written to the local database. + +## Endpoints + +Public holdings data routes support CORS, `GET`, and `OPTIONS`. When database caching is enabled, history, breakdown, activity, activity facets, and protocol-return history rate-limit by forwarded IP, falling back to a simple header fingerprint. `/api/holdings/progress` is read-only progress polling and does not run the rate limiter. + +### `GET /api/holdings/history` + +Daily holdings chart. + +Examples: + +```bash +curl "http://localhost:3001/api/holdings/history?address=0x..." +curl "http://localhost:3001/api/holdings/history?address=0x...&denomination=eth&timeframe=all" +curl "http://localhost:3001/api/holdings/history?address=0x...&vaults=1:0x...,1:0x..." +``` + +Query params: + +| Param | Required | Default | Description | +|-------|----------|---------|-------------| +| `address` | Yes | - | User EVM address | +| `version` | No | `all` | `v2`, `v3`, or `all` | +| `denomination` | No | `usd` | `usd` or `eth` | +| `timeframe` | No | `1y` | `1y` or `all` | +| `vault` + `chainId` | No | - | Single family vault filter | +| `vaults` | No | - | Comma-separated multi-vault filter, e.g. `1:0xvault,8453:0xvault` | +| `fetchType` | No | `seq` | `seq` or `parallel` | +| `paginationMode` | No | `paged` | `paged` or `all` | +| `progressId` | No | - | Stable progress ID clients can poll through `/api/holdings/progress` | +| `debug` | No | - | Enables the route debug context | +| `refresh` | Local only | `false` | `true` or `1` clears the user's cached totals before computing | +| `debugLots`, `debugVault`, `debugTx` | Local only | - | Extra debug filters in `api/server.ts` | + +Response: + +```json +{ + "address": "0x...", + "version": "all", + "denomination": "usd", + "timeframe": "1y", + "dataPoints": [ + { "date": "2026-05-05", "value": 1000.5 }, + { "date": "2026-05-06", "value": 1005.25 } + ] +} +``` + +Returns `404` when the wallet has no indexed holdings activity for the request. + +### `GET /api/holdings/progress` + +Reads Redis-backed progress for long-running holdings routes. `history` and `protocol-return/history` can write progress when the caller passes a valid `progressId`. + +Example: + +```bash +curl "http://localhost:3001/api/holdings/progress?id=portfolio:0x..." +``` + +Query params: + +| Param | Required | Default | Description | +|-------|----------|---------|-------------| +| `id` | Yes | - | Progress ID previously passed to a progress-enabled holdings route | + +Response: + +```json +{ + "id": "portfolio:0x...", + "route": "history", + "addressHash": "sha256...", + "status": "running", + "progress": 45, + "message": "Fetching historical prices", + "detail": null, + "startedAt": 1778111999000, + "updatedAt": 1778112005000, + "logs": [] +} +``` + +Progress records expire after 10 minutes. The route returns `404` when the ID is invalid, expired, missing, or Redis progress is unavailable, and it always sends `Cache-Control: no-store`. + +### `GET /api/holdings/breakdown` + +Per-vault valuation for a settled UTC date. Without `date`, it uses the latest settled holdings-history day. + +Examples: + +```bash +curl "http://localhost:3001/api/holdings/breakdown?address=0x..." +curl "http://localhost:3001/api/holdings/breakdown?address=0x...&date=2026-05-06" +``` + +Query params: + +| Param | Required | Default | Description | +|-------|----------|---------|-------------| +| `address` | Yes | - | User EVM address | +| `date` | No | latest settled UTC day | UTC date in `YYYY-MM-DD` format | +| `version` | No | `all` | `v2`, `v3`, or `all` | +| `fetchType` | No | `seq` | `seq` or `parallel` | +| `paginationMode` | No | `paged` | `paged` or `all` | +| `debug`, `debugLots`, `debugVault`, `debugTx` | Local only | - | Debug logging controls in `api/server.ts` | + +Response is intentionally verbose because it is used to explain the latest chart point: + +```json +{ + "address": "0x...", + "version": "all", + "date": "2026-05-06", + "timestamp": 1778111999, + "summary": { + "totalVaults": 3, + "vaultsWithShares": 2, + "totalUsdValue": 1250.5, + "missingMetadata": 0, + "missingPps": 0, + "missingPrice": 1 + }, + "vaults": [ + { + "chainId": 1, + "vaultAddress": "0x...", + "shares": "1000000000000000000", + "sharesFormatted": 1, + "pricePerShare": 1.05, + "tokenPrice": 1, + "usdValue": 1.05, + "metadata": { + "symbol": "USDC", + "decimals": 18, + "tokenAddress": "0x..." + }, + "status": "ok" + } + ], + "issues": { + "missingMetadata": [], + "missingPps": [], + "missingPrice": ["1:0x..."] + } +} +``` + +### `GET /api/holdings/activity` + +Recent classified vault activity. + +```bash +curl "http://localhost:3001/api/holdings/activity?address=0x..." +curl "http://localhost:3001/api/holdings/activity?address=0x...&limit=20&offset=20" +curl "http://localhost:3001/api/holdings/activity?address=0x...&type=withdraw&chainId=1&includeFacets=1" +``` + +Query params: + +| Param | Required | Default | Description | +|-------|----------|---------|-------------| +| `address` | Yes | - | User EVM address | +| `version` | No | `all` | `v2`, `v3`, or `all` | +| `limit` | No | `10` | Integer clamped to `1..50` | +| `offset` | No | `0` | Non-negative integer | +| `type` | No | `all` | `deposit`, `withdraw`, `stake`, `unstake`, `transfer`, `swap`, or `all` | +| `chainId` | No | - | Positive integer chain filter | +| `startTimestamp` | No | - | Inclusive Unix timestamp lower bound | +| `endTimestamp` | No | - | Inclusive Unix timestamp upper bound | +| `includeFacets` | No | `false` | `true` or `1` includes `facets.chainIds` for the returned page | + +Response (`facets` appears only when `includeFacets=true` or `includeFacets=1`): + +```json +{ + "address": "0x...", + "version": "all", + "limit": 10, + "offset": 0, + "facets": { + "chainIds": [1, 8453] + }, + "pageInfo": { + "hasMore": true, + "nextOffset": 10 + }, + "entries": [ + { + "chainId": 1, + "txHash": "0x...", + "timestamp": 1778111999, + "action": "deposit", + "displayType": "zap", + "transferDirection": "in", + "vaultAddress": "0x...", + "familyVaultAddress": "0x...", + "assetSymbol": "USDC", + "assetAmount": "1000000", + "assetAmountFormatted": 1, + "inputTokenAddress": "0x...", + "inputTokenSymbol": "USDC", + "inputTokenAmount": "1000000", + "inputTokenAmountFormatted": 1, + "outputTokenAddress": "0x...", + "outputTokenSymbol": "yvUSDC", + "outputTokenAmount": "1000000", + "outputTokenAmountFormatted": 1, + "shareAmount": "1000000", + "shareAmountFormatted": 1, + "status": "ok" + } + ] +} +``` + +Activity classification merges address-scoped events with transaction-scoped context, so router-mediated staking, unstaking, deposit, withdraw, transfer, and swap flows can be represented as user actions. Configure `VITE_RPC_URI_FOR_` for richer receipt enrichment of zaps, reward claims, and direct V2 vault actions; without it the API still returns indexed activity rows, but some enriched input/output fields may be absent. + +### `GET /api/holdings/activity-facets` + +Returns activity chain facets without fetching the full paginated activity response. This is useful for building chain filter controls before the user requests activity rows. + +```bash +curl "http://localhost:3001/api/holdings/activity-facets?address=0x..." +curl "http://localhost:3001/api/holdings/activity-facets?address=0x...&limitPerSource=500&offsetPerSource=500" +``` + +Query params: + +| Param | Required | Default | Description | +|-------|----------|---------|-------------| +| `address` | Yes | - | User EVM address | +| `version` | No | `all` | `v2`, `v3`, or `all` | +| `limitPerSource` | No | `250` | Per-event-source page size, clamped to `1..1000` | +| `offsetPerSource` | No | `0` | Per-event-source non-negative offset | + +Response: + +```json +{ + "address": "0x...", + "version": "all", + "facets": { + "chainIds": [1, 8453] + }, + "pageInfo": { + "hasMore": false, + "nextOffsetPerSource": null + } +} +``` + +### `GET /api/holdings/protocol-return/history` + +Protocol-return history for a user's vault exposure. This is not a cost-basis PnL engine. It measures how much Yearn changed the user's withdrawable underlying amount while the user held vault shares. Receipt-time token prices weight different assets into one portfolio percentage. + +The compatibility alias `/api/holdings/pnl/simple-history` routes to the same handler. + +Examples: + +```bash +curl "http://localhost:3001/api/holdings/protocol-return/history?address=0x..." +curl "http://localhost:3001/api/holdings/protocol-return/history?address=0x...&timeframe=all" +curl "http://localhost:3001/api/holdings/protocol-return/history?address=0x...&vaults=1:0x...,1:0x..." +``` + +Query params: + +| Param | Required | Default | Description | +|-------|----------|---------|-------------| +| `address` | Yes | - | User EVM address | +| `version` | No | `all` | `v2`, `v3`, or `all` | +| `timeframe` | No | `1y` | `1y` or `all` | +| `vault` + `chainId` | No | - | Single family vault filter | +| `vaults` | No | - | Comma-separated multi-vault filter | +| `fetchType` | No | `seq` | `seq` or `parallel` | +| `paginationMode` | No | `paged` | `paged` or `all` | +| `progressId` | No | - | Stable progress ID clients can poll through `/api/holdings/progress` | +| `debug` | No | - | Enables the route debug context | +| `debugLots`, `debugVault`, `debugTx` | Local only | - | Extra debug filters in `api/server.ts` | + +Metric model: + +```text +baselineUnderlying = shares received * PPS at receipt +growthUnderlying = withdrawable underlying now-or-at-exit - baselineUnderlying +baselineWeightUsd = baselineUnderlying * receiptTokenPriceUsd +growthWeightUsd = growthUnderlying * receiptTokenPriceUsd +protocolReturnPct = growthWeightUsd / baselineWeightUsd * 100 +``` + +Because numerator and denominator use the same receipt-time token price, later asset price movement does not affect `protocolReturnPct`. + +Response: + +```json +{ + "address": "0x...", + "version": "all", + "timeframe": "1y", + "generatedAt": "2026-05-07T00:00:00.000Z", + "summary": { + "totalVaults": 5, + "completeVaults": 5, + "partialVaults": 0, + "recommendedGrowthDisplay": "index", + "recommendedGrowthDisplayReason": "mixed", + "openBaselineCompositionUsd": { + "stable": 100, + "ethFamily": 50, + "other": 0 + }, + "isComplete": true + }, + "dataPoints": [ + { + "date": "2026-05-06", + "timestamp": 1778111999, + "growthWeightUsd": 100, + "growthWeightEth": null, + "protocolReturnPct": 10, + "annualizedProtocolReturnPct": 12, + "growthIndex": 110 + } + ], + "familySeries": [] +} +``` + +When a vault filter is present, each history point can also include `currentUnderlying`, `growthUnderlying`, `sharesFormatted`, and `pricePerShare`. + +### `POST /api/admin/invalidate-cache` + +Marks vaults as invalidated so affected user daily totals are lazily cleared and recomputed on the next cached history request. Requires `x-admin-secret: $ADMIN_SECRET` and DB caching. + +```bash +curl -X POST \ + -H "content-type: application/json" \ + -H "x-admin-secret: $ADMIN_SECRET" \ + -d '{"vaults":[{"address":"0x...","chainId":1}]}' \ + "http://localhost:3001/api/admin/invalidate-cache" +``` + +Response: + +```json +{ + "success": true, + "invalidated": 1, + "vaults": ["1:0x..."], + "timestamp": "2026-05-07T00:00:00.000Z" +} +``` + +## Supported Chains + +| Chain | ID | Price prefix | +|-------|----|--------------| +| Ethereum | 1 | `ethereum` | +| Optimism | 10 | `optimism` | +| Fantom | 250 | `fantom` | +| Polygon | 137 | `polygon` | +| Base | 8453 | `base` | +| Arbitrum | 42161 | `arbitrum` | +| Katana | 747474 | `katana` | + +`getChainPrefix` falls back to `ethereum` for unknown chain IDs, so new chains should be added to `SUPPORTED_CHAINS` before requests are expected to value correctly. + +## Environment Variables + +| Variable | Required | Default | Description | +|----------|----------|---------|-------------| +| `ENVIO_GRAPHQL_URL` | No | `http://localhost:8080/v1/graphql` | Envio indexer GraphQL endpoint | +| `ENVIO_PASSWORD` | No | `''` | Envio Hasura admin secret; skipped when empty or `testing` | +| `UPSTASH_REDIS_REST_URL_PORTFOLIO` | No | `null` | Upstash Redis REST URL for holdings cache, progress, rate limits, and invalidations | +| `UPSTASH_REDIS_REST_TOKEN_PORTFOLIO` | No | `null` | Upstash Redis REST token for holdings storage | +| `VITE_RPC_URI_FOR_` | No | `null` | Optional chain RPC URL for activity receipt and transaction enrichment | +| `HOLDINGS_PRICE_PROVIDER` | No | `auto` | `auto`, `yearn-prices`, or `defillama` | +| `YEARN_PRICES_BASE_URL` | No | `https://prices.yearn.dev` | Base URL for yearn-prices; `/api/prices/...` is appended automatically | +| `YEARN_PRICES_API_URL` | No | `YEARN_PRICES_BASE_URL` fallback | Legacy alias for `YEARN_PRICES_BASE_URL` | +| `YEARN_PRICES_API_KEY` | No | `API_KEY_PORTFOLIO` fallback | Bearer token for yearn-prices | +| `API_KEY_PORTFOLIO` | No | `''` | Shared portfolio API key used as the yearn-prices fallback token | +| `DEFILLAMA_API_KEY` | No | `''` | Enables DefiLlama Pro GET route | +| `ADMIN_SECRET` | Admin only | `null` | Secret for `/api/admin/invalidate-cache` | +| `HOLDINGS_DEBUG` | Local only | `false` | Enables holdings debug logs in `api/server.ts` | + +Hardcoded service bases: + +- Kong: `https://kong.yearn.fi` +- DefiLlama free: `https://coins.llama.fi` +- DefiLlama pro: `https://pro-api.llama.fi` + +## Event Fetching + +Envio hosted GraphQL has a practical `1000`-row page limit. Holdings routes expose controls for fetching address-scoped event families: + +- `fetchType=seq`: fetch each event family through sequential `limit/offset` pages. +- `fetchType=parallel`: use aggregate counts when available, then fetch pages for event families concurrently. +- `paginationMode=paged`: use normal page-by-page fetching. +- `paginationMode=all`: issue one large query per event family. This is primarily for benchmarking and experiments. + +`parallel` depends on aggregate roots being available: + +- `Deposit_aggregate`, `Withdraw_aggregate` +- `V2Deposit_aggregate`, `V2Withdraw_aggregate` +- `Transfer_aggregate` + +If aggregates are unavailable, the code falls back to sequential pagination. For most production traffic, `fetchType=parallel&paginationMode=paged` is the preferred fast path. + +## Caching + +Server-side cache is optional. When `UPSTASH_REDIS_REST_URL_PORTFOLIO` or `UPSTASH_REDIS_REST_TOKEN_PORTFOLIO` is absent, the APIs still work but recompute history and refetch prices/PPS on each request. + +### Cache Layers + +1. Upstash Redis: + - `holdings:totals::`: daily USD totals per hashed user address, vault version, and date. Hash fields are `YYYY-MM-DD`; values include `usdValue` and `updatedAt`. + - `holdings:rate-limit:`: simple per-client request windows with a 60-second TTL. + - `holdings:vault-invalidated::`: per-vault invalidation timestamps for lazy cache clearing. + - `holdings:progress:`: authoritative short-lived progress records keyed by caller-supplied progress ID for long history requests across Vercel function instances. +2. HTTP cache: + - History, breakdown, and protocol-return history: `s-maxage=300, stale-while-revalidate=600`. + - Activity: `s-maxage=60, stale-while-revalidate=300`. + - Activity facets: `s-maxage=300, stale-while-revalidate=900`. + - Progress: `no-store`. +3. Client TanStack Query cache: + - Portfolio history and protocol-return history hooks keep chart responses fresh for one hour. + - Other frontend hooks configure their own durations. + +### Daily Totals + +The history cache stores aggregate daily totals, not per-vault breakdown rows. Cache keys use SHA-256 of the normalized user address, not the raw address. + +Cache behavior: + +- Unfiltered history can read/write `holdings:totals::`. +- Vault-filtered history skips aggregate daily total cache because the cache is user/version scoped, not vault-filter scoped. +- Cache staleness is checked against `holdings:vault-invalidated::` only after the request has enough cached daily totals to potentially serve from cache. +- If any relevant vault was invalidated after the oldest cached row was written, the user's cached totals for that version are cleared and recomputed. +- Recalculated totals are not cached when any token price batch failed, because partial price data can undercount chart totals. +- `refresh=true` or `refresh=1` in the local Bun server clears the user's cached totals before recomputing. + +### Progress and Rate Limits + +- Progress writes only when Redis persistence is enabled and the supplied `progressId` matches `[a-zA-Z0-9:_-]{1,160}`. +- Progress status is `running`, `complete`, or `error`; progress is clamped to `0..100`, logs are capped to the latest `20` entries, and rows expire after `10 minutes`. +- Redis-backed rate limiting allows `10` requests per minute per client identifier. If Redis access fails, the rate limiter allows the request and logs the failure. + +### Token Prices + +Token prices are fetched from the selected provider for each request. Holdings Redis storage does not cache positive token prices or price misses. + +## Redis Keys + +No schema migration is required. Redis keys are created lazily: + +| Key | Type | TTL | Purpose | +|-----|------|-----|---------| +| `holdings:totals::` | Hash | 30 days from write | Daily holdings chart totals. | +| `holdings:rate-limit:` | String counter | 60 seconds | Public route rate limiting. | +| `holdings:vault-invalidated::` | String timestamp | None | Lazy invalidation marker for totals cache. | +| `holdings:progress:` | String JSON record | 10 minutes | Progress polling state for long requests. | + +## Operational Notes + +- Enable Redis storage in shared environments; otherwise a history request must rebuild events, PPS, and prices every time. +- Keep `API_KEY_PORTFOLIO` or `YEARN_PRICES_API_KEY` configured if `HOLDINGS_PRICE_PROVIDER=auto` should prefer yearn-prices. +- Configure `VITE_RPC_URI_FOR_` for chains where activity rows should include richer zap, reward-claim, and direct V2 vault enrichment. +- Pass a stable `progressId` from the frontend for long history and protocol-return requests, then poll `/api/holdings/progress?id=...`; progress rows are Redis-backed and expire quickly. +- Use `/api/admin/invalidate-cache` after indexer deployments add or repair vault coverage. +- Rate-limit and progress cleanup is handled by Redis TTLs. +- If Redis progress is unavailable, clients show a neutral loading placeholder instead of estimated progress. +- `timeframe=all` grows over time from `2024-01-01`, so cache row counts are no longer fixed at `365` per user/version. diff --git a/api/lib/holdings/config.ts b/api/lib/holdings/config.ts new file mode 100644 index 000000000..3c809015d --- /dev/null +++ b/api/lib/holdings/config.ts @@ -0,0 +1,59 @@ +export interface HoldingsConfig { + readonly envioGraphqlUrl: string + readonly envioPassword: string + readonly redisUrl: string | null + readonly redisToken: string | null + readonly kongBaseUrl: string + readonly yearnPricesBaseUrl: string + readonly yearnPricesApiKey: string + readonly defillamaBaseUrl: string + readonly defillamaProBaseUrl: string + readonly defillamaApiKey: string + readonly historyDays: number + readonly historyStartTimestamp: number +} + +const HISTORY_START_TIMESTAMP = 1_704_067_200 // 2024-01-01T00:00:00Z +const YEARN_PRICES_BASE_URL = 'https://prices.yearn.dev' + +export const holdingsConfig: HoldingsConfig = { + get envioGraphqlUrl() { + return process.env.ENVIO_GRAPHQL_URL ?? 'http://localhost:8080/v1/graphql' + }, + get envioPassword() { + return process.env.ENVIO_PASSWORD ?? '' + }, + get redisUrl() { + return process.env.UPSTASH_REDIS_REST_URL_PORTFOLIO?.trim() || null + }, + get redisToken() { + return process.env.UPSTASH_REDIS_REST_TOKEN_PORTFOLIO?.trim() || null + }, + kongBaseUrl: 'https://kong.yearn.fi', + get yearnPricesBaseUrl() { + return (process.env.YEARN_PRICES_BASE_URL ?? process.env.YEARN_PRICES_API_URL ?? YEARN_PRICES_BASE_URL) + .trim() + .replace(/\/$/, '') + }, + get yearnPricesApiKey() { + return (process.env.YEARN_PRICES_API_KEY ?? process.env.API_KEY_PORTFOLIO ?? '').trim() + }, + defillamaBaseUrl: 'https://coins.llama.fi', + defillamaProBaseUrl: 'https://pro-api.llama.fi', + get defillamaApiKey() { + return process.env.DEFILLAMA_API_KEY?.trim() ?? '' + }, + historyDays: 365, + historyStartTimestamp: HISTORY_START_TIMESTAMP +} + +export function validateConfig(): void { + if (!process.env.ENVIO_GRAPHQL_URL) { + console.warn('[Holdings] ENVIO_GRAPHQL_URL not set, using default localhost:8080') + } + if (!process.env.UPSTASH_REDIS_REST_URL_PORTFOLIO || !process.env.UPSTASH_REDIS_REST_TOKEN_PORTFOLIO) { + console.warn( + '[Holdings] UPSTASH_REDIS_REST_URL_PORTFOLIO / UPSTASH_REDIS_REST_TOKEN_PORTFOLIO not set, storage disabled' + ) + } +} diff --git a/api/lib/holdings/index.ts b/api/lib/holdings/index.ts new file mode 100644 index 000000000..de303ff3b --- /dev/null +++ b/api/lib/holdings/index.ts @@ -0,0 +1,43 @@ +export { holdingsConfig, validateConfig } from './config' +export { + getHoldingsActivity, + getHoldingsActivityFacets, + type HoldingsActivityAction, + type HoldingsActivityEntry, + type HoldingsActivityFilters, + type HoldingsActivityResponse, + type HoldingsActivityTypeFilter +} from './services/activity' +export { + getHistoricalHoldings, + getHistoricalHoldingsChart, + getHoldingsBreakdown, + getHoldingsTotalsCacheVersion, + type HoldingsBreakdownResponse, + type HoldingsBreakdownVaultResponse, + type HoldingsHistoryChartResponse, + type HoldingsHistoryDenomination, + type HoldingsHistoryResponse, + type HoldingsHistoryTimeframe, + type HoldingsVaultFilter +} from './services/aggregator' +export { clearUserCache } from './services/cache' +export { + fetchRecentAddressScopedActivityEvents, + fetchUserEvents, + type HoldingsEventFetchType, + type HoldingsEventPaginationMode, + type VaultVersion +} from './services/graphql' +export { + getHoldingsProtocolReturnHistory, + type HoldingsPnLSimpleHistoryPoint, + type HoldingsPnLSimpleHistoryResponse, + type THoldingsPnLSimpleStatus +} from './services/pnlSimple' +export { checkRateLimit } from './services/ratelimit' +export { + ensureHoldingsStorageInitialized, + initializeHoldingsStorage, + isHoldingsStorageEnabled +} from './storage/redis' diff --git a/api/lib/holdings/services/activity.test.ts b/api/lib/holdings/services/activity.test.ts new file mode 100644 index 000000000..db7faa7f8 --- /dev/null +++ b/api/lib/holdings/services/activity.test.ts @@ -0,0 +1,2827 @@ +import { + encodeAbiParameters, + encodeEventTopics, + encodeFunctionData, + encodeFunctionResult, + erc20Abi, + parseAbiItem +} from 'viem' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const fetchRecentAddressScopedActivityEventsMock = vi.fn() +const fetchActivityEventsByTransactionHashesMock = vi.fn() +const fetchUserEventsMock = vi.fn() +const fetchMultipleVaultsMetadataMock = vi.fn() + +vi.mock('./graphql', () => ({ + fetchRecentAddressScopedActivityEvents: fetchRecentAddressScopedActivityEventsMock, + fetchActivityEventsByTransactionHashes: fetchActivityEventsByTransactionHashesMock, + fetchUserEvents: fetchUserEventsMock +})) + +vi.mock('./vaults', () => ({ + fetchMultipleVaultsMetadata: fetchMultipleVaultsMetadataMock +})) + +const UNDERLYING_VAULT = '0xbe53a109b494e5c9f97b9cd39fe969be68bf6204' +const STAKING_VAULT = '0x622fa41799406b120f9a40da843d358b7b2cfee3' +const DESTINATION_VAULT = '0x3333333333333333333333333333333333333333' +const HIDDEN_VAULT = '0x0000000000000000000000000000000000000123' +const UNKNOWN_VAULT = '0x0000000000000000000000000000000000000456' +const USER_ADDRESS = '0x2222222222222222222222222222222222222222' +const INTERMEDIARY = '0x4Fe93ebC4Ce6Ae4f81601cC7Ce7139023919E003' +const USDT0 = '0x5555555555555555555555555555555555555555' +const YBOLD_VAULT = '0x9f4330700a36b29952869fac9b33f45eedd8a3d8' +const YSYBOLD_VAULT = '0x23346b04a7f55b8760e5860aa5a77383d63491cd' +const YCRV_ZAP = '0x78ada385b15d89a9b845d2cac0698663f0c69e3c' +const ZAPPER_V2 = '0x42D4e90Ff4068Abe7BC4EaB838c7dE1D2F5998A3' +const ZAPPER_V2_ZAP_IN = '0x92Be6ADB6a12Da0CA607F9d87DB2F9978cD6ec3E' +const ZAPPER_V2_ZAP_OUT = '0xd6b88257e91e4E4D4E990B3A858c849EF2DFdE8c' +const DAI = '0x6B175474E89094C44Da98b954EedeAC495271d0F' +const YBS_REWARD_DISTRIBUTOR = '0xB226c52EB411326CdB54824a88aBaFDAAfF16D3d' +const YYB_REWARD_DISTRIBUTOR = '0x1d02F6A86Ed5650f93E40FCD62fa5727c32ad746' +const TRANSFER_EVENT = parseAbiItem('event Transfer(address indexed from, address indexed to, uint256 value)') +const ZAPPER_ZAP_IN_EVENT = parseAbiItem('event zapIn(address sender, address pool, uint256 tokensRec)') +const ZAPPER_ZAP_OUT_EVENT = parseAbiItem( + 'event zapOut(address sender, address pool, address token, uint256 tokensRec)' +) +const YCRV_ZAP_TEST_ABI = [ + { + stateMutability: 'nonpayable', + type: 'function', + name: 'zap', + inputs: [ + { name: '_input_token', type: 'address' }, + { name: '_output_token', type: 'address' }, + { name: '_amount_in', type: 'uint256' }, + { name: '_min_out', type: 'uint256' }, + { name: '_recipient', type: 'address' } + ], + outputs: [{ name: '', type: 'uint256' }] + } +] as const +const V2_VAULT_TEST_ABI = [ + { + stateMutability: 'nonpayable', + type: 'function', + name: 'deposit', + inputs: [{ name: '_amount', type: 'uint256' }], + outputs: [{ name: '', type: 'uint256' }] + }, + { + stateMutability: 'nonpayable', + type: 'function', + name: 'withdraw', + inputs: [{ name: '_maxShares', type: 'uint256' }], + outputs: [{ name: '', type: 'uint256' }] + } +] as const + +function createDepositEvent(args: { + id: string + vaultAddress: string + transactionHash: string + blockTimestamp: number + logIndex: number + assets: string + shares?: string + sender?: string + chainId?: number +}) { + return { + id: args.id, + vaultAddress: args.vaultAddress, + chainId: args.chainId ?? 1, + blockNumber: args.blockTimestamp, + blockTimestamp: args.blockTimestamp, + logIndex: args.logIndex, + transactionHash: args.transactionHash, + transactionFrom: USER_ADDRESS, + owner: USER_ADDRESS, + sender: args.sender ?? USER_ADDRESS, + assets: args.assets, + shares: args.shares ?? args.assets + } +} + +function createWithdrawalEvent(args: { + id: string + vaultAddress: string + transactionHash: string + blockTimestamp: number + logIndex: number + assets: string + shares?: string + chainId?: number +}) { + return { + id: args.id, + vaultAddress: args.vaultAddress, + chainId: args.chainId ?? 1, + blockNumber: args.blockTimestamp, + blockTimestamp: args.blockTimestamp, + logIndex: args.logIndex, + transactionHash: args.transactionHash, + transactionFrom: USER_ADDRESS, + owner: USER_ADDRESS, + assets: args.assets, + shares: args.shares ?? args.assets + } +} + +function createTransferEvent(args: { + id: string + vaultAddress: string + transactionHash: string + blockTimestamp: number + logIndex: number + value: string + sender: string + receiver: string + chainId?: number +}) { + return { + id: args.id, + vaultAddress: args.vaultAddress, + chainId: args.chainId ?? 1, + blockNumber: args.blockTimestamp, + blockTimestamp: args.blockTimestamp, + logIndex: args.logIndex, + transactionHash: args.transactionHash, + transactionFrom: USER_ADDRESS, + sender: args.sender, + receiver: args.receiver, + value: args.value + } +} + +function createTransferLog(args: { tokenAddress: string; from: string; to: string; value: bigint }) { + return { + address: args.tokenAddress, + data: encodeAbiParameters([{ type: 'uint256' }], [args.value]), + topics: encodeEventTopics({ + abi: [TRANSFER_EVENT], + eventName: 'Transfer', + args: { + from: args.from, + to: args.to + } + }), + logIndex: '0x1' + } +} + +function createZapperZapInLog(args: { sender: string; pool: string; tokensRec: bigint; address?: string }) { + return { + address: args.address ?? ZAPPER_V2, + data: encodeAbiParameters( + [{ type: 'address' }, { type: 'address' }, { type: 'uint256' }], + [args.sender, args.pool, args.tokensRec] + ), + topics: encodeEventTopics({ + abi: [ZAPPER_ZAP_IN_EVENT], + eventName: 'zapIn' + }), + logIndex: '0x2' + } +} + +function createZapperZapOutLog(args: { + sender: string + pool: string + token: string + tokensRec: bigint + address?: string +}) { + return { + address: args.address ?? ZAPPER_V2_ZAP_OUT, + data: encodeAbiParameters( + [{ type: 'address' }, { type: 'address' }, { type: 'address' }, { type: 'uint256' }], + [args.sender, args.pool, args.token, args.tokensRec] + ), + topics: encodeEventTopics({ + abi: [ZAPPER_ZAP_OUT_EVENT], + eventName: 'zapOut' + }), + logIndex: '0x2' + } +} + +function mockReceiptEnrichmentRpc(args: { + tokenAddress: string + tokenSymbol: string + tokenDecimals: number + logs?: ReturnType[] +}) { + process.env.VITE_RPC_URI_FOR_1 = 'https://rpc.example' + vi.stubGlobal( + 'fetch', + vi.fn(async (_url: string, init?: RequestInit) => { + const body = JSON.parse(String(init?.body ?? '{}')) as { + method?: string + params?: Array<{ data?: string }> + } + + if (body.method === 'eth_getTransactionReceipt') { + return new Response( + JSON.stringify({ + result: { + logs: args.logs ?? [ + createTransferLog({ + tokenAddress: args.tokenAddress, + from: USER_ADDRESS, + to: INTERMEDIARY, + value: 230000n + }) + ] + } + }) + ) + } + + if (body.method === 'eth_call') { + const data = body.params?.[0]?.data + + return new Response( + JSON.stringify({ + result: + data === '0x95d89b41' + ? encodeFunctionResult({ + abi: erc20Abi, + functionName: 'symbol', + result: args.tokenSymbol + }) + : encodeFunctionResult({ + abi: erc20Abi, + functionName: 'decimals', + result: args.tokenDecimals + }) + }) + ) + } + + return new Response(JSON.stringify({ result: null })) + }) + ) +} + +function mockYcrvZapRpc(args: { + inputTokenAddress: string + outputTokenAddress: string + inputAmount: bigint + inputTokenSymbol: string + inputTokenDecimals: number +}) { + process.env.VITE_RPC_URI_FOR_1 = 'https://rpc.example' + vi.stubGlobal( + 'fetch', + vi.fn(async (_url: string, init?: RequestInit) => { + const body = JSON.parse(String(init?.body ?? '{}')) as { + method?: string + params?: Array<{ data?: string }> + } + + if (body.method === 'eth_getTransactionByHash') { + return new Response( + JSON.stringify({ + result: { + to: YCRV_ZAP, + input: encodeFunctionData({ + abi: YCRV_ZAP_TEST_ABI, + functionName: 'zap', + args: [args.inputTokenAddress, args.outputTokenAddress, args.inputAmount, 1n, USER_ADDRESS] + }) + } + }) + ) + } + + if (body.method === 'eth_call') { + const data = body.params?.[0]?.data + + return new Response( + JSON.stringify({ + result: + data === '0x95d89b41' + ? encodeFunctionResult({ + abi: erc20Abi, + functionName: 'symbol', + result: args.inputTokenSymbol + }) + : encodeFunctionResult({ + abi: erc20Abi, + functionName: 'decimals', + result: args.inputTokenDecimals + }) + }) + ) + } + + return new Response(JSON.stringify({ result: null })) + }) + ) +} + +function mockRewardDistributorRpc(transactionTargets: Record) { + process.env.VITE_RPC_URI_FOR_1 = 'https://rpc.example' + vi.stubGlobal( + 'fetch', + vi.fn(async (_url: string, init?: RequestInit) => { + const body = JSON.parse(String(init?.body ?? '{}')) as { + method?: string + params?: string[] + } + const transactionHash = body.params?.[0] + + return new Response( + JSON.stringify({ + result: + body.method === 'eth_getTransactionByHash' && transactionHash + ? { + to: transactionTargets[transactionHash] ?? INTERMEDIARY, + input: '0x' + } + : null + }) + ) + }) + ) +} + +function mockDirectV2VaultRpc(args: { + transactionHash: string + vaultAddress: string + action: 'deposit' | 'withdraw' + assetAmount: bigint + underlyingTokenAddress?: string +}) { + process.env.VITE_RPC_URI_FOR_1 = 'https://rpc.example' + vi.stubGlobal( + 'fetch', + vi.fn(async (_url: string, init?: RequestInit) => { + const body = JSON.parse(String(init?.body ?? '{}')) as { + method?: string + params?: string[] + } + + if (body.method === 'eth_getTransactionByHash' && body.params?.[0] === args.transactionHash) { + return new Response( + JSON.stringify({ + result: { + to: args.vaultAddress, + input: encodeFunctionData({ + abi: V2_VAULT_TEST_ABI, + functionName: args.action, + args: [args.assetAmount] + }) + } + }) + ) + } + + if (body.method === 'eth_getTransactionReceipt' && args.action === 'withdraw') { + return new Response( + JSON.stringify({ + result: { + logs: [ + createTransferLog({ + tokenAddress: args.underlyingTokenAddress ?? USDT0, + from: args.vaultAddress, + to: USER_ADDRESS, + value: args.assetAmount + }) + ] + } + }) + ) + } + + return new Response(JSON.stringify({ result: null })) + }) + ) +} + +function mockZapperV2Rpc(args: { + transactionHash: string + transactionTo?: string + zapSender?: string + zapPool?: string + tokensRec?: bigint + inputTokenAddress?: string + inputAmount?: bigint + inputTokenSymbol?: string + inputTokenDecimals?: number +}) { + process.env.VITE_RPC_URI_FOR_1 = 'https://rpc.example' + vi.stubGlobal( + 'fetch', + vi.fn(async (_url: string, init?: RequestInit) => { + const zapperContract = args.transactionTo ?? ZAPPER_V2 + const body = JSON.parse(String(init?.body ?? '{}')) as { + method?: string + params?: Array + } + + if (body.method === 'eth_getTransactionByHash' && body.params?.[0] === args.transactionHash) { + return new Response( + JSON.stringify({ + result: { + to: zapperContract, + input: '0x82650b10' + } + }) + ) + } + + if (body.method === 'eth_getTransactionReceipt') { + return new Response( + JSON.stringify({ + result: { + logs: [ + createTransferLog({ + tokenAddress: args.inputTokenAddress ?? DAI, + from: USER_ADDRESS, + to: zapperContract, + value: args.inputAmount ?? 100000000000000000000n + }), + createTransferLog({ + tokenAddress: args.zapPool ?? UNDERLYING_VAULT, + from: zapperContract, + to: USER_ADDRESS, + value: args.tokensRec ?? 50741940577121965627316n + }), + createZapperZapInLog({ + address: zapperContract, + sender: args.zapSender ?? USER_ADDRESS, + pool: args.zapPool ?? UNDERLYING_VAULT, + tokensRec: args.tokensRec ?? 50741940577121965627316n + }) + ] + } + }) + ) + } + + if (body.method === 'eth_call') { + const [call] = body.params ?? [] + const data = typeof call === 'object' ? call.data : null + + return new Response( + JSON.stringify({ + result: + data === '0x95d89b41' + ? encodeFunctionResult({ + abi: erc20Abi, + functionName: 'symbol', + result: args.inputTokenSymbol ?? 'DAI' + }) + : encodeFunctionResult({ + abi: erc20Abi, + functionName: 'decimals', + result: args.inputTokenDecimals ?? 18 + }) + }) + ) + } + + return new Response(JSON.stringify({ result: null })) + }) + ) +} + +function mockZapperV2ZapOutRpc(args: { + transactionHash: string + transactionTo?: string + zapSender?: string + zapPool?: string + shareAmount: bigint + outputTokenAddress?: string + outputAmount: bigint + outputTokenSymbol?: string + outputTokenDecimals?: number +}) { + process.env.VITE_RPC_URI_FOR_1 = 'https://rpc.example' + vi.stubGlobal( + 'fetch', + vi.fn(async (_url: string, init?: RequestInit) => { + const zapperContract = args.transactionTo ?? ZAPPER_V2_ZAP_OUT + const body = JSON.parse(String(init?.body ?? '{}')) as { + method?: string + params?: Array + } + + if (body.method === 'eth_getTransactionByHash' && body.params?.[0] === args.transactionHash) { + return new Response( + JSON.stringify({ + result: { + to: zapperContract, + input: '0x89c6973b' + } + }) + ) + } + + if (body.method === 'eth_getTransactionReceipt') { + return new Response( + JSON.stringify({ + result: { + logs: [ + createTransferLog({ + tokenAddress: args.zapPool ?? UNDERLYING_VAULT, + from: USER_ADDRESS, + to: zapperContract, + value: args.shareAmount + }), + createTransferLog({ + tokenAddress: args.outputTokenAddress ?? USDT0, + from: zapperContract, + to: USER_ADDRESS, + value: args.outputAmount + }), + createZapperZapOutLog({ + address: zapperContract, + sender: args.zapSender ?? USER_ADDRESS, + pool: args.zapPool ?? UNDERLYING_VAULT, + token: args.outputTokenAddress ?? USDT0, + tokensRec: args.outputAmount + }) + ] + } + }) + ) + } + + if (body.method === 'eth_call') { + const [call] = body.params ?? [] + const data = typeof call === 'object' ? call.data : null + + return new Response( + JSON.stringify({ + result: + data === '0x95d89b41' + ? encodeFunctionResult({ + abi: erc20Abi, + functionName: 'symbol', + result: args.outputTokenSymbol ?? 'USDT' + }) + : encodeFunctionResult({ + abi: erc20Abi, + functionName: 'decimals', + result: args.outputTokenDecimals ?? 6 + }) + }) + ) + } + + return new Response(JSON.stringify({ result: null })) + }) + ) +} + +describe('getHoldingsActivity', () => { + beforeEach(() => { + vi.resetModules() + vi.clearAllMocks() + vi.unstubAllGlobals() + delete process.env.VITE_RPC_URI_FOR_1 + fetchActivityEventsByTransactionHashesMock.mockResolvedValue({ + deposits: [], + withdrawals: [], + transfers: [] + }) + fetchUserEventsMock.mockResolvedValue({ + deposits: [], + withdrawals: [], + transfersIn: [], + transfersOut: [] + }) + }) + + it('aggregates recent events, classifies staking actions, and filters hidden vaults', async () => { + fetchRecentAddressScopedActivityEventsMock.mockResolvedValue({ + deposits: [ + createDepositEvent({ + id: 'deposit-1', + vaultAddress: UNDERLYING_VAULT, + transactionHash: '0xaaa', + blockTimestamp: 200, + logIndex: 3, + assets: '1500000', + shares: '1500000000000000000' + }), + createDepositEvent({ + id: 'deposit-2', + vaultAddress: UNDERLYING_VAULT, + transactionHash: '0xaaa', + blockTimestamp: 200, + logIndex: 2, + assets: '500000', + shares: '500000000000000000' + }), + createDepositEvent({ + id: 'stake-1', + vaultAddress: STAKING_VAULT, + transactionHash: '0xbbb', + blockTimestamp: 190, + logIndex: 1, + assets: '2000000000000000000' + }), + createDepositEvent({ + id: 'hidden-1', + vaultAddress: HIDDEN_VAULT, + transactionHash: '0xddd', + blockTimestamp: 170, + logIndex: 1, + assets: '1000000' + }) + ], + withdrawals: [ + createWithdrawalEvent({ + id: 'unstake-1', + vaultAddress: STAKING_VAULT, + transactionHash: '0xccc', + blockTimestamp: 180, + logIndex: 1, + assets: '1000000000000000000' + }) + ], + transfersIn: [], + transfersOut: [], + hasMoreDeposits: false, + hasMoreWithdrawals: false, + hasMoreTransfersIn: false, + hasMoreTransfersOut: false + }) + + fetchMultipleVaultsMetadataMock.mockResolvedValue( + new Map([ + [ + `1:${UNDERLYING_VAULT}`, + { + address: UNDERLYING_VAULT, + chainId: 1, + version: 'v3', + category: 'stable', + token: { + address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + symbol: 'USDC', + decimals: 6 + }, + decimals: 18 + } + ], + [ + `1:${STAKING_VAULT}`, + { + address: STAKING_VAULT, + chainId: 1, + version: 'v3', + category: 'stable', + token: { + address: UNDERLYING_VAULT, + symbol: 'yvUSDC', + decimals: 18 + }, + decimals: 18 + } + ], + [ + `1:${HIDDEN_VAULT}`, + { + address: HIDDEN_VAULT, + chainId: 1, + version: 'v3', + isHidden: true, + category: 'stable', + token: { + address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + symbol: 'USDC', + decimals: 6 + }, + decimals: 18 + } + ] + ]) + ) + + const { getHoldingsActivity } = await import('./activity') + const response = await getHoldingsActivity(USER_ADDRESS, 'all', 4) + + expect(fetchRecentAddressScopedActivityEventsMock).toHaveBeenCalledWith(USER_ADDRESS, 'all', 20) + expect(response.entries).toEqual([ + { + chainId: 1, + txHash: '0xaaa', + timestamp: 200, + action: 'deposit', + transferDirection: null, + vaultAddress: UNDERLYING_VAULT, + familyVaultAddress: UNDERLYING_VAULT, + assetSymbol: 'USDC', + assetAmount: '2000000', + assetAmountFormatted: 2, + inputTokenAddress: null, + inputTokenSymbol: null, + inputTokenAmount: null, + inputTokenAmountFormatted: null, + outputTokenAddress: null, + outputTokenSymbol: null, + outputTokenAmount: null, + outputTokenAmountFormatted: null, + shareAmount: '2000000000000000000', + shareAmountFormatted: 2, + status: 'ok' + }, + { + chainId: 1, + txHash: '0xbbb', + timestamp: 190, + action: 'stake', + transferDirection: null, + vaultAddress: STAKING_VAULT, + familyVaultAddress: UNDERLYING_VAULT, + assetSymbol: 'yvUSDC', + assetAmount: '2000000000000000000', + assetAmountFormatted: 2, + inputTokenAddress: null, + inputTokenSymbol: null, + inputTokenAmount: null, + inputTokenAmountFormatted: null, + outputTokenAddress: null, + outputTokenSymbol: null, + outputTokenAmount: null, + outputTokenAmountFormatted: null, + shareAmount: '2000000000000000000', + shareAmountFormatted: 2, + status: 'ok' + }, + { + chainId: 1, + txHash: '0xccc', + timestamp: 180, + action: 'unstake', + transferDirection: null, + vaultAddress: STAKING_VAULT, + familyVaultAddress: UNDERLYING_VAULT, + assetSymbol: 'yvUSDC', + assetAmount: '1000000000000000000', + assetAmountFormatted: 1, + inputTokenAddress: null, + inputTokenSymbol: null, + inputTokenAmount: null, + inputTokenAmountFormatted: null, + outputTokenAddress: null, + outputTokenSymbol: null, + outputTokenAmount: null, + outputTokenAmountFormatted: null, + shareAmount: '1000000000000000000', + shareAmountFormatted: 1, + status: 'ok' + } + ]) + expect(response.pageInfo).toEqual({ + hasMore: false, + nextOffset: null + }) + }) + + it('enriches router-mediated deposits with the user input token from the tx receipt', async () => { + fetchRecentAddressScopedActivityEventsMock.mockResolvedValue({ + deposits: [ + createDepositEvent({ + id: 'enso-deposit', + vaultAddress: UNDERLYING_VAULT, + transactionHash: '0xenso', + blockTimestamp: 230, + logIndex: 2, + assets: '230118', + shares: '202094', + sender: INTERMEDIARY + }) + ], + withdrawals: [], + transfersIn: [], + transfersOut: [], + hasMoreDeposits: false, + hasMoreWithdrawals: false, + hasMoreTransfersIn: false, + hasMoreTransfersOut: false + }) + fetchMultipleVaultsMetadataMock.mockResolvedValue( + new Map([ + [ + `1:${UNDERLYING_VAULT}`, + { + address: UNDERLYING_VAULT, + chainId: 1, + version: 'v3', + category: 'stable', + token: { + address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + symbol: 'USDC', + decimals: 6 + }, + decimals: 6 + } + ] + ]) + ) + mockReceiptEnrichmentRpc({ + tokenAddress: USDT0, + tokenSymbol: 'USDT0', + tokenDecimals: 6 + }) + + const { getHoldingsActivity } = await import('./activity') + const response = await getHoldingsActivity(USER_ADDRESS, 'all', 10) + + expect(response.entries).toEqual([ + { + chainId: 1, + txHash: '0xenso', + timestamp: 230, + action: 'deposit', + transferDirection: null, + vaultAddress: UNDERLYING_VAULT, + familyVaultAddress: UNDERLYING_VAULT, + assetSymbol: 'USDC', + assetAmount: '230118', + assetAmountFormatted: 0.230118, + inputTokenAddress: USDT0.toLowerCase(), + inputTokenSymbol: 'USDT0', + inputTokenAmount: '230000', + inputTokenAmountFormatted: 0.23, + outputTokenAddress: null, + outputTokenSymbol: null, + outputTokenAmount: null, + outputTokenAmountFormatted: null, + shareAmount: '202094', + shareAmountFormatted: 0.202094, + status: 'ok' + } + ]) + }) + + it('collapses router-mediated vault exits and entries in the same transaction into a swap row', async () => { + fetchRecentAddressScopedActivityEventsMock.mockResolvedValue({ + deposits: [], + withdrawals: [], + transfersIn: [ + createTransferEvent({ + id: 'enso-swap-transfer-in', + vaultAddress: DESTINATION_VAULT, + transactionHash: '0xensoswap', + blockTimestamp: 240, + logIndex: 8, + value: '37000000000000000000', + sender: INTERMEDIARY, + receiver: USER_ADDRESS + }) + ], + transfersOut: [ + createTransferEvent({ + id: 'enso-swap-transfer-out', + vaultAddress: UNDERLYING_VAULT, + transactionHash: '0xensoswap', + blockTimestamp: 240, + logIndex: 1, + value: '27000000000000000000', + sender: USER_ADDRESS, + receiver: INTERMEDIARY + }) + ], + hasMoreDeposits: false, + hasMoreWithdrawals: false, + hasMoreTransfersIn: false, + hasMoreTransfersOut: false + }) + fetchActivityEventsByTransactionHashesMock.mockResolvedValue({ + deposits: [ + createDepositEvent({ + id: 'enso-swap-deposit', + vaultAddress: DESTINATION_VAULT, + transactionHash: '0xensoswap', + blockTimestamp: 240, + logIndex: 7, + assets: '39000000000000000000', + shares: '37000000000000000000', + sender: INTERMEDIARY + }) + ], + withdrawals: [ + createWithdrawalEvent({ + id: 'enso-swap-withdraw', + vaultAddress: UNDERLYING_VAULT, + transactionHash: '0xensoswap', + blockTimestamp: 240, + logIndex: 2, + assets: '28000000000000000000', + shares: '27000000000000000000' + }) + ], + transfers: [ + createTransferEvent({ + id: 'enso-swap-transfer-in', + vaultAddress: DESTINATION_VAULT, + transactionHash: '0xensoswap', + blockTimestamp: 240, + logIndex: 8, + value: '37000000000000000000', + sender: INTERMEDIARY, + receiver: USER_ADDRESS + }), + createTransferEvent({ + id: 'enso-swap-transfer-out', + vaultAddress: UNDERLYING_VAULT, + transactionHash: '0xensoswap', + blockTimestamp: 240, + logIndex: 1, + value: '27000000000000000000', + sender: USER_ADDRESS, + receiver: INTERMEDIARY + }) + ] + }) + fetchMultipleVaultsMetadataMock.mockResolvedValue( + new Map([ + [ + `1:${UNDERLYING_VAULT}`, + { + address: UNDERLYING_VAULT, + chainId: 1, + version: 'v3', + category: 'stable', + token: { + address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + symbol: 'USDC', + decimals: 6 + }, + decimals: 18 + } + ], + [ + `1:${DESTINATION_VAULT}`, + { + address: DESTINATION_VAULT, + chainId: 1, + version: 'v3', + category: 'volatile', + token: { + address: '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2', + symbol: 'WETH', + decimals: 18 + }, + decimals: 18 + } + ] + ]) + ) + + const { getHoldingsActivity } = await import('./activity') + const allResponse = await getHoldingsActivity(USER_ADDRESS, 'all', 10) + const swapResponse = await getHoldingsActivity(USER_ADDRESS, 'all', 10, 0, { type: 'swap' }) + const depositResponse = await getHoldingsActivity(USER_ADDRESS, 'all', 10, 0, { type: 'deposit' }) + const withdrawResponse = await getHoldingsActivity(USER_ADDRESS, 'all', 10, 0, { type: 'withdraw' }) + + expect(allResponse.entries).toEqual([ + { + chainId: 1, + txHash: '0xensoswap', + timestamp: 240, + action: 'swap', + transferDirection: null, + vaultAddress: DESTINATION_VAULT, + familyVaultAddress: DESTINATION_VAULT, + assetSymbol: 'WETH', + assetAmount: '0', + assetAmountFormatted: null, + inputTokenAddress: UNDERLYING_VAULT, + inputTokenSymbol: null, + inputTokenAmount: '27000000000000000000', + inputTokenAmountFormatted: 27, + outputTokenAddress: null, + outputTokenSymbol: null, + outputTokenAmount: null, + outputTokenAmountFormatted: null, + shareAmount: '37000000000000000000', + shareAmountFormatted: 37, + status: 'ok' + } + ]) + expect(swapResponse.entries).toHaveLength(1) + expect(depositResponse.entries).toEqual([]) + expect(withdrawResponse.entries).toEqual([]) + }) + + it('collapses direct-classified vault exits and entries in the same transaction into a swap row', async () => { + fetchRecentAddressScopedActivityEventsMock.mockResolvedValue({ + deposits: [ + createDepositEvent({ + id: 'enso-direct-swap-deposit', + vaultAddress: DESTINATION_VAULT, + transactionHash: '0xensodirectswap', + blockTimestamp: 245, + logIndex: 7, + assets: '39000000000000000000', + shares: '37000000000000000000', + sender: INTERMEDIARY + }) + ], + withdrawals: [ + createWithdrawalEvent({ + id: 'enso-direct-swap-withdraw', + vaultAddress: UNDERLYING_VAULT, + transactionHash: '0xensodirectswap', + blockTimestamp: 245, + logIndex: 2, + assets: '28000000000000000000', + shares: '27000000000000000000' + }) + ], + transfersIn: [], + transfersOut: [], + hasMoreDeposits: false, + hasMoreWithdrawals: false, + hasMoreTransfersIn: false, + hasMoreTransfersOut: false + }) + fetchMultipleVaultsMetadataMock.mockResolvedValue( + new Map([ + [ + `1:${UNDERLYING_VAULT}`, + { + address: UNDERLYING_VAULT, + chainId: 1, + version: 'v3', + category: 'stable', + token: { + address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + symbol: 'USDC', + decimals: 6 + }, + decimals: 18 + } + ], + [ + `1:${DESTINATION_VAULT}`, + { + address: DESTINATION_VAULT, + chainId: 1, + version: 'v3', + category: 'volatile', + token: { + address: '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2', + symbol: 'WETH', + decimals: 18 + }, + decimals: 18 + } + ] + ]) + ) + + const { getHoldingsActivity } = await import('./activity') + const response = await getHoldingsActivity(USER_ADDRESS, 'all', 10) + + expect(response.entries.map((entry) => [entry.action, entry.txHash, entry.vaultAddress])).toEqual([ + ['swap', '0xensodirectswap', DESTINATION_VAULT] + ]) + }) + + it('keeps entries with missing metadata and null formatted amount', async () => { + fetchRecentAddressScopedActivityEventsMock.mockResolvedValue({ + deposits: [], + withdrawals: [ + createWithdrawalEvent({ + id: 'unknown-1', + vaultAddress: UNKNOWN_VAULT, + transactionHash: '0xeee', + blockTimestamp: 220, + logIndex: 1, + assets: '123456789' + }) + ], + transfersIn: [], + transfersOut: [], + hasMoreDeposits: false, + hasMoreWithdrawals: false, + hasMoreTransfersIn: false, + hasMoreTransfersOut: false + }) + fetchMultipleVaultsMetadataMock.mockResolvedValue(new Map()) + + const { getHoldingsActivity } = await import('./activity') + const response = await getHoldingsActivity(USER_ADDRESS, 'all', 10) + + expect(response.entries).toEqual([ + { + chainId: 1, + txHash: '0xeee', + timestamp: 220, + action: 'withdraw', + transferDirection: null, + vaultAddress: UNKNOWN_VAULT, + familyVaultAddress: UNKNOWN_VAULT, + assetSymbol: null, + assetAmount: '123456789', + assetAmountFormatted: null, + inputTokenAddress: null, + inputTokenSymbol: null, + inputTokenAmount: null, + inputTokenAmountFormatted: null, + outputTokenAddress: null, + outputTokenSymbol: null, + outputTokenAmount: null, + outputTokenAmountFormatted: null, + shareAmount: '123456789', + shareAmountFormatted: null, + status: 'missing_metadata' + } + ]) + expect(response.pageInfo).toEqual({ + hasMore: false, + nextOffset: null + }) + }) + + it('returns later pages and exposes next offset when more transactions exist', async () => { + fetchRecentAddressScopedActivityEventsMock.mockResolvedValue({ + deposits: [ + createDepositEvent({ + id: 'deposit-page-1', + vaultAddress: UNDERLYING_VAULT, + transactionHash: '0xaaa', + blockTimestamp: 300, + logIndex: 2, + assets: '1000000', + shares: '1000000000000000000' + }), + createDepositEvent({ + id: 'deposit-page-2', + vaultAddress: UNDERLYING_VAULT, + transactionHash: '0xbbb', + blockTimestamp: 290, + logIndex: 2, + assets: '2000000', + shares: '2000000000000000000' + }), + createDepositEvent({ + id: 'deposit-page-3', + vaultAddress: UNDERLYING_VAULT, + transactionHash: '0xccc', + blockTimestamp: 280, + logIndex: 2, + assets: '3000000', + shares: '3000000000000000000' + }) + ], + withdrawals: [], + transfersIn: [], + transfersOut: [], + hasMoreDeposits: true, + hasMoreWithdrawals: false, + hasMoreTransfersIn: false, + hasMoreTransfersOut: false + }) + + fetchMultipleVaultsMetadataMock.mockResolvedValue( + new Map([ + [ + `1:${UNDERLYING_VAULT}`, + { + address: UNDERLYING_VAULT, + chainId: 1, + version: 'v3', + category: 'stable', + token: { + address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + symbol: 'USDC', + decimals: 6 + }, + decimals: 18 + } + ] + ]) + ) + + const { getHoldingsActivity } = await import('./activity') + const response = await getHoldingsActivity(USER_ADDRESS, 'all', 1, 1) + + expect(fetchRecentAddressScopedActivityEventsMock).toHaveBeenCalledWith(USER_ADDRESS, 'all', 20) + expect(response.entries).toEqual([ + { + chainId: 1, + txHash: '0xbbb', + timestamp: 290, + action: 'deposit', + transferDirection: null, + vaultAddress: UNDERLYING_VAULT, + familyVaultAddress: UNDERLYING_VAULT, + assetSymbol: 'USDC', + assetAmount: '2000000', + assetAmountFormatted: 2, + inputTokenAddress: null, + inputTokenSymbol: null, + inputTokenAmount: null, + inputTokenAmountFormatted: null, + outputTokenAddress: null, + outputTokenSymbol: null, + outputTokenAmount: null, + outputTokenAmountFormatted: null, + shareAmount: '2000000000000000000', + shareAmountFormatted: 2, + status: 'ok' + } + ]) + expect(response.pageInfo).toEqual({ + hasMore: true, + nextOffset: 2 + }) + }) + + it('filters activity by action before paginating', async () => { + fetchRecentAddressScopedActivityEventsMock.mockResolvedValue({ + deposits: [ + createDepositEvent({ + id: 'deposit-filter-1', + vaultAddress: UNDERLYING_VAULT, + transactionHash: '0xaaa', + blockTimestamp: 300, + logIndex: 2, + assets: '1000000' + }), + createDepositEvent({ + id: 'deposit-filter-2', + vaultAddress: UNDERLYING_VAULT, + transactionHash: '0xbbb', + blockTimestamp: 290, + logIndex: 2, + assets: '2000000' + }) + ], + withdrawals: [ + createWithdrawalEvent({ + id: 'withdraw-filter-1', + vaultAddress: UNDERLYING_VAULT, + transactionHash: '0xccc', + blockTimestamp: 280, + logIndex: 2, + assets: '3000000' + }) + ], + transfersIn: [], + transfersOut: [], + hasMoreDeposits: false, + hasMoreWithdrawals: false, + hasMoreTransfersIn: false, + hasMoreTransfersOut: false + }) + + fetchMultipleVaultsMetadataMock.mockResolvedValue( + new Map([ + [ + `1:${UNDERLYING_VAULT}`, + { + address: UNDERLYING_VAULT, + chainId: 1, + version: 'v3', + category: 'stable', + token: { + address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + symbol: 'USDC', + decimals: 6 + }, + decimals: 18 + } + ] + ]) + ) + + const { getHoldingsActivity } = await import('./activity') + const response = await getHoldingsActivity(USER_ADDRESS, 'all', 1, 0, { type: 'withdraw' }) + + expect(fetchRecentAddressScopedActivityEventsMock).toHaveBeenCalledWith(USER_ADDRESS, 'all', 80) + expect(response.entries).toEqual([ + { + chainId: 1, + txHash: '0xccc', + timestamp: 280, + action: 'withdraw', + transferDirection: null, + vaultAddress: UNDERLYING_VAULT, + familyVaultAddress: UNDERLYING_VAULT, + assetSymbol: 'USDC', + assetAmount: '3000000', + assetAmountFormatted: 3, + inputTokenAddress: null, + inputTokenSymbol: null, + inputTokenAmount: null, + inputTokenAmountFormatted: null, + outputTokenAddress: null, + outputTokenSymbol: null, + outputTokenAmount: null, + outputTokenAmountFormatted: null, + shareAmount: '3000000', + shareAmountFormatted: 0.000000000003, + status: 'ok' + } + ]) + expect(response.pageInfo).toEqual({ + hasMore: false, + nextOffset: null + }) + }) + + it('filters activity by chain before paginating', async () => { + fetchRecentAddressScopedActivityEventsMock.mockResolvedValue({ + deposits: [ + createDepositEvent({ + id: 'deposit-chain-1', + vaultAddress: UNDERLYING_VAULT, + transactionHash: '0xaaa', + blockTimestamp: 300, + logIndex: 2, + assets: '1000000' + }), + { + ...createDepositEvent({ + id: 'deposit-chain-2', + vaultAddress: UNDERLYING_VAULT, + transactionHash: '0xbbb', + blockTimestamp: 290, + logIndex: 2, + assets: '2000000' + }), + chainId: 137 + } + ], + withdrawals: [], + transfersIn: [], + transfersOut: [], + hasMoreDeposits: false, + hasMoreWithdrawals: false, + hasMoreTransfersIn: false, + hasMoreTransfersOut: false + }) + + fetchUserEventsMock.mockResolvedValue({ + deposits: [ + createDepositEvent({ + id: 'deposit-chain-1', + vaultAddress: UNDERLYING_VAULT, + transactionHash: '0xaaa', + blockTimestamp: 300, + logIndex: 2, + assets: '1000000' + }), + { + ...createDepositEvent({ + id: 'deposit-chain-2', + vaultAddress: UNDERLYING_VAULT, + transactionHash: '0xbbb', + blockTimestamp: 290, + logIndex: 2, + assets: '2000000' + }), + chainId: 137 + } + ], + withdrawals: [], + transfersIn: [], + transfersOut: [] + }) + + fetchMultipleVaultsMetadataMock.mockResolvedValue( + new Map([ + [ + `137:${UNDERLYING_VAULT}`, + { + address: UNDERLYING_VAULT, + chainId: 137, + version: 'v3', + category: 'stable', + token: { + address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + symbol: 'USDC', + decimals: 6 + }, + decimals: 18 + } + ] + ]) + ) + + const { getHoldingsActivity } = await import('./activity') + const response = await getHoldingsActivity(USER_ADDRESS, 'all', 1, 0, { chainId: 137 }) + + expect(response.entries).toEqual([ + { + chainId: 137, + txHash: '0xbbb', + timestamp: 290, + action: 'deposit', + transferDirection: null, + vaultAddress: UNDERLYING_VAULT, + familyVaultAddress: UNDERLYING_VAULT, + assetSymbol: 'USDC', + assetAmount: '2000000', + assetAmountFormatted: 2, + inputTokenAddress: null, + inputTokenSymbol: null, + inputTokenAmount: null, + inputTokenAmountFormatted: null, + outputTokenAddress: null, + outputTokenSymbol: null, + outputTokenAmount: null, + outputTokenAmountFormatted: null, + shareAmount: '2000000', + shareAmountFormatted: 0.000000000002, + status: 'ok' + } + ]) + }) + + it('uses the bounded filtered scanner for chain-filtered activity', async () => { + const baseVault = '0xc3bd0a2193c8f027b82dde3611d18589ef3f62a9' + const baseDeposit = { + ...createDepositEvent({ + id: 'deposit-base-older', + vaultAddress: baseVault, + transactionHash: '0xeae5d579a571e592719d0815674744238a49993e7a7322c29d81b88343ef1c7b', + blockTimestamp: 100, + logIndex: 1, + assets: '3000000' + }), + chainId: 8453 + } + + fetchRecentAddressScopedActivityEventsMock.mockResolvedValue({ + deposits: [ + createDepositEvent({ + id: 'deposit-mainnet-newer', + vaultAddress: UNDERLYING_VAULT, + transactionHash: '0xaaa', + blockTimestamp: 500, + logIndex: 1, + assets: '1000000' + }), + baseDeposit + ], + withdrawals: [], + transfersIn: [], + transfersOut: [], + hasMoreDeposits: false, + hasMoreWithdrawals: false, + hasMoreTransfersIn: false, + hasMoreTransfersOut: false + }) + fetchMultipleVaultsMetadataMock.mockResolvedValue( + new Map([ + [ + `8453:${baseVault}`, + { + address: baseVault, + chainId: 8453, + version: 'v3', + category: 'stable', + token: { + address: '0x4200000000000000000000000000000000000006', + symbol: 'WETH', + decimals: 18 + }, + decimals: 18 + } + ] + ]) + ) + + const { getHoldingsActivity } = await import('./activity') + const response = await getHoldingsActivity(USER_ADDRESS, 'all', 1, 0, { chainId: 8453 }) + + expect(fetchRecentAddressScopedActivityEventsMock).toHaveBeenCalledWith(USER_ADDRESS, 'all', 80) + expect(fetchUserEventsMock).not.toHaveBeenCalled() + expect(fetchActivityEventsByTransactionHashesMock).toHaveBeenCalledWith( + new Map([[8453, ['0xeae5d579a571e592719d0815674744238a49993e7a7322c29d81b88343ef1c7b']]]), + 'all' + ) + expect(response.entries).toEqual([ + { + chainId: 8453, + txHash: '0xeae5d579a571e592719d0815674744238a49993e7a7322c29d81b88343ef1c7b', + timestamp: 100, + action: 'deposit', + transferDirection: null, + vaultAddress: baseVault, + familyVaultAddress: baseVault, + assetSymbol: 'WETH', + assetAmount: '3000000', + assetAmountFormatted: 0.000000000003, + inputTokenAddress: null, + inputTokenSymbol: null, + inputTokenAmount: null, + inputTokenAmountFormatted: null, + outputTokenAddress: null, + outputTokenSymbol: null, + outputTokenAmount: null, + outputTokenAmountFormatted: null, + shareAmount: '3000000', + shareAmountFormatted: 0.000000000003, + status: 'ok' + } + ]) + expect(response.pageInfo).toEqual({ + hasMore: false, + nextOffset: null + }) + }) + + it('filters activity by timestamp range before paginating', async () => { + fetchRecentAddressScopedActivityEventsMock.mockResolvedValue({ + deposits: [ + createDepositEvent({ + id: 'deposit-range-1', + vaultAddress: UNDERLYING_VAULT, + transactionHash: '0xaaa', + blockTimestamp: 300, + logIndex: 2, + assets: '1000000' + }), + createDepositEvent({ + id: 'deposit-range-2', + vaultAddress: UNDERLYING_VAULT, + transactionHash: '0xbbb', + blockTimestamp: 250, + logIndex: 2, + assets: '2000000' + }), + createDepositEvent({ + id: 'deposit-range-3', + vaultAddress: UNDERLYING_VAULT, + transactionHash: '0xccc', + blockTimestamp: 200, + logIndex: 2, + assets: '3000000' + }) + ], + withdrawals: [], + transfersIn: [], + transfersOut: [], + hasMoreDeposits: false, + hasMoreWithdrawals: false, + hasMoreTransfersIn: false, + hasMoreTransfersOut: false + }) + + fetchMultipleVaultsMetadataMock.mockResolvedValue( + new Map([ + [ + `1:${UNDERLYING_VAULT}`, + { + address: UNDERLYING_VAULT, + chainId: 1, + version: 'v3', + category: 'stable', + token: { + address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + symbol: 'USDC', + decimals: 6 + }, + decimals: 18 + } + ] + ]) + ) + + const { getHoldingsActivity } = await import('./activity') + const response = await getHoldingsActivity(USER_ADDRESS, 'all', 1, 0, { + startTimestamp: 240, + endTimestamp: 260 + }) + + expect(fetchRecentAddressScopedActivityEventsMock).toHaveBeenCalledWith(USER_ADDRESS, 'all', 80, 260) + expect(response.entries).toEqual([ + { + chainId: 1, + txHash: '0xbbb', + timestamp: 250, + action: 'deposit', + transferDirection: null, + vaultAddress: UNDERLYING_VAULT, + familyVaultAddress: UNDERLYING_VAULT, + assetSymbol: 'USDC', + assetAmount: '2000000', + assetAmountFormatted: 2, + inputTokenAddress: null, + inputTokenSymbol: null, + inputTokenAmount: null, + inputTokenAmountFormatted: null, + outputTokenAddress: null, + outputTokenSymbol: null, + outputTokenAmount: null, + outputTokenAmountFormatted: null, + shareAmount: '2000000', + shareAmountFormatted: 0.000000000002, + status: 'ok' + } + ]) + }) + + it('seeks date-filtered scans from the selected end timestamp', async () => { + fetchRecentAddressScopedActivityEventsMock.mockImplementation( + async (_userAddress: string, _version: string, _limitPerSource: number, maxTimestamp?: number) => ({ + deposits: + maxTimestamp === 260 + ? [ + createDepositEvent({ + id: 'older-in-range', + vaultAddress: UNDERLYING_VAULT, + transactionHash: '0xolder', + blockTimestamp: 250, + logIndex: 2, + assets: '2000000' + }) + ] + : [ + createDepositEvent({ + id: 'newer-out-of-range', + vaultAddress: UNDERLYING_VAULT, + transactionHash: '0xnewer', + blockTimestamp: 500, + logIndex: 2, + assets: '1000000' + }) + ], + withdrawals: [], + transfersIn: [], + transfersOut: [], + hasMoreDeposits: false, + hasMoreWithdrawals: false, + hasMoreTransfersIn: false, + hasMoreTransfersOut: false + }) + ) + fetchMultipleVaultsMetadataMock.mockResolvedValue( + new Map([ + [ + `1:${UNDERLYING_VAULT}`, + { + address: UNDERLYING_VAULT, + chainId: 1, + version: 'v3', + category: 'stable', + token: { + address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + symbol: 'USDC', + decimals: 6 + }, + decimals: 18 + } + ] + ]) + ) + + const { getHoldingsActivity } = await import('./activity') + const response = await getHoldingsActivity(USER_ADDRESS, 'all', 1, 0, { + startTimestamp: 240, + endTimestamp: 260 + }) + + expect(fetchRecentAddressScopedActivityEventsMock).toHaveBeenCalledWith(USER_ADDRESS, 'all', 80, 260) + expect(response.entries.map((entry) => entry.txHash)).toEqual(['0xolder']) + expect(response.pageInfo).toEqual({ + hasMore: false, + nextOffset: null + }) + }) + + it('keeps scanning when filtered chain matches are sparse', async () => { + fetchRecentAddressScopedActivityEventsMock + .mockResolvedValueOnce({ + deposits: Array.from({ length: 20 }, (_, index) => + createDepositEvent({ + id: `mainnet-recent-${index}`, + vaultAddress: UNDERLYING_VAULT, + transactionHash: `0xmainnet${index}`, + blockTimestamp: 500 - index, + logIndex: 2, + assets: '1000000', + chainId: 1 + }) + ), + withdrawals: [], + transfersIn: [], + transfersOut: [], + hasMoreDeposits: true, + hasMoreWithdrawals: false, + hasMoreTransfersIn: false, + hasMoreTransfersOut: false + }) + .mockResolvedValueOnce({ + deposits: [ + ...Array.from({ length: 20 }, (_, index) => + createDepositEvent({ + id: `mainnet-expanded-${index}`, + vaultAddress: UNDERLYING_VAULT, + transactionHash: `0xmainnet${index}`, + blockTimestamp: 500 - index, + logIndex: 2, + assets: '1000000', + chainId: 1 + }) + ), + createDepositEvent({ + id: 'base-match', + vaultAddress: UNDERLYING_VAULT, + transactionHash: '0xbase', + blockTimestamp: 300, + logIndex: 2, + assets: '2000000', + chainId: 8453 + }) + ], + withdrawals: [], + transfersIn: [], + transfersOut: [], + hasMoreDeposits: false, + hasMoreWithdrawals: false, + hasMoreTransfersIn: false, + hasMoreTransfersOut: false + }) + + fetchMultipleVaultsMetadataMock.mockResolvedValue( + new Map([ + [ + `8453:${UNDERLYING_VAULT}`, + { + address: UNDERLYING_VAULT, + chainId: 8453, + version: 'v3', + category: 'stable', + token: { + address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + symbol: 'USDC', + decimals: 6 + }, + decimals: 18 + } + ] + ]) + ) + + const { getHoldingsActivity } = await import('./activity') + const response = await getHoldingsActivity(USER_ADDRESS, 'all', 1, 0, { + chainId: 8453 + }) + + expect(fetchRecentAddressScopedActivityEventsMock).toHaveBeenNthCalledWith(1, USER_ADDRESS, 'all', 80) + expect(fetchRecentAddressScopedActivityEventsMock).toHaveBeenNthCalledWith(2, USER_ADDRESS, 'all', 160) + expect(response.entries.map((entry) => [entry.chainId, entry.txHash])).toEqual([[8453, '0xbase']]) + expect(response.pageInfo).toEqual({ + hasMore: false, + nextOffset: null + }) + }) + + it('emits fallback transfer-in activity for address-scoped vault share transfers', async () => { + fetchRecentAddressScopedActivityEventsMock.mockResolvedValue({ + deposits: [], + withdrawals: [], + transfersIn: [ + createTransferEvent({ + id: 'transfer-in-1', + vaultAddress: UNDERLYING_VAULT, + transactionHash: '0xtransferin', + blockTimestamp: 410, + logIndex: 2, + value: '1230000000000000000', + sender: INTERMEDIARY, + receiver: USER_ADDRESS + }) + ], + transfersOut: [], + hasMoreDeposits: false, + hasMoreWithdrawals: false, + hasMoreTransfersIn: false, + hasMoreTransfersOut: false + }) + fetchMultipleVaultsMetadataMock.mockResolvedValue( + new Map([ + [ + `1:${UNDERLYING_VAULT}`, + { + address: UNDERLYING_VAULT, + chainId: 1, + version: 'v3', + category: 'stable', + token: { + address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + symbol: 'USDC', + decimals: 6 + }, + decimals: 18 + } + ] + ]) + ) + + const { getHoldingsActivity } = await import('./activity') + const response = await getHoldingsActivity(USER_ADDRESS, 'all', 10) + + expect(response.entries).toEqual([ + { + chainId: 1, + txHash: '0xtransferin', + timestamp: 410, + action: 'transfer', + transferDirection: 'in', + vaultAddress: UNDERLYING_VAULT, + familyVaultAddress: UNDERLYING_VAULT, + assetSymbol: 'USDC', + assetAmount: '0', + assetAmountFormatted: null, + inputTokenAddress: null, + inputTokenSymbol: null, + inputTokenAmount: null, + inputTokenAmountFormatted: null, + outputTokenAddress: null, + outputTokenSymbol: null, + outputTokenAmount: null, + outputTokenAmountFormatted: null, + shareAmount: '1230000000000000000', + shareAmountFormatted: 1.23, + status: 'ok' + } + ]) + }) + + it('emits fallback transfer-out activity for address-scoped vault share transfers', async () => { + fetchRecentAddressScopedActivityEventsMock.mockResolvedValue({ + deposits: [], + withdrawals: [], + transfersIn: [], + transfersOut: [ + createTransferEvent({ + id: 'transfer-out-raw-1', + vaultAddress: UNDERLYING_VAULT, + transactionHash: '0xtransferout', + blockTimestamp: 405, + logIndex: 2, + value: '2500000000000000000', + sender: USER_ADDRESS, + receiver: INTERMEDIARY + }) + ], + hasMoreDeposits: false, + hasMoreWithdrawals: false, + hasMoreTransfersIn: false, + hasMoreTransfersOut: false + }) + fetchMultipleVaultsMetadataMock.mockResolvedValue( + new Map([ + [ + `1:${UNDERLYING_VAULT}`, + { + address: UNDERLYING_VAULT, + chainId: 1, + version: 'v3', + category: 'stable', + token: { + address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + symbol: 'USDC', + decimals: 6 + }, + decimals: 18 + } + ] + ]) + ) + + const { getHoldingsActivity } = await import('./activity') + const response = await getHoldingsActivity(USER_ADDRESS, 'all', 10) + + expect(response.entries).toMatchObject([ + { + action: 'transfer', + transferDirection: 'out', + assetAmount: '0', + assetAmountFormatted: null, + shareAmount: '2500000000000000000', + shareAmountFormatted: 2.5 + } + ]) + }) + + it('classifies direct v2 vault mint transfers as deposits from transaction input', async () => { + mockDirectV2VaultRpc({ + transactionHash: '0xv2deposit', + vaultAddress: UNDERLYING_VAULT, + action: 'deposit', + assetAmount: 4000000n + }) + fetchRecentAddressScopedActivityEventsMock.mockResolvedValue({ + deposits: [], + withdrawals: [], + transfersIn: [ + createTransferEvent({ + id: 'v2-deposit-mint-transfer', + vaultAddress: UNDERLYING_VAULT, + transactionHash: '0xv2deposit', + blockTimestamp: 412, + logIndex: 2, + value: '3900000000000000000', + sender: '0x0000000000000000000000000000000000000000', + receiver: USER_ADDRESS + }) + ], + transfersOut: [], + hasMoreDeposits: false, + hasMoreWithdrawals: false, + hasMoreTransfersIn: false, + hasMoreTransfersOut: false + }) + fetchMultipleVaultsMetadataMock.mockResolvedValue( + new Map([ + [ + `1:${UNDERLYING_VAULT}`, + { + address: UNDERLYING_VAULT, + chainId: 1, + version: 'v2', + category: 'stable', + token: { + address: USDT0, + symbol: 'USDT', + decimals: 6 + }, + decimals: 18 + } + ] + ]) + ) + + const { getHoldingsActivity } = await import('./activity') + const response = await getHoldingsActivity(USER_ADDRESS, 'all', 10) + + expect(response.entries).toMatchObject([ + { + action: 'deposit', + transferDirection: null, + assetAmount: '4000000', + assetAmountFormatted: 4, + shareAmount: '3900000000000000000', + shareAmountFormatted: 3.9 + } + ]) + }) + + it('classifies direct v2 vault burn transfers as withdrawals from transaction receipt output', async () => { + mockDirectV2VaultRpc({ + transactionHash: '0xv2withdraw', + vaultAddress: UNDERLYING_VAULT, + action: 'withdraw', + assetAmount: 4300000n, + underlyingTokenAddress: USDT0 + }) + fetchRecentAddressScopedActivityEventsMock.mockResolvedValue({ + deposits: [], + withdrawals: [], + transfersIn: [], + transfersOut: [ + createTransferEvent({ + id: 'v2-withdraw-burn-transfer', + vaultAddress: UNDERLYING_VAULT, + transactionHash: '0xv2withdraw', + blockTimestamp: 411, + logIndex: 2, + value: '4100000000000000000', + sender: USER_ADDRESS, + receiver: '0x0000000000000000000000000000000000000000' + }) + ], + hasMoreDeposits: false, + hasMoreWithdrawals: false, + hasMoreTransfersIn: false, + hasMoreTransfersOut: false + }) + fetchMultipleVaultsMetadataMock.mockResolvedValue( + new Map([ + [ + `1:${UNDERLYING_VAULT}`, + { + address: UNDERLYING_VAULT, + chainId: 1, + version: 'v2', + category: 'stable', + token: { + address: USDT0, + symbol: 'USDT', + decimals: 6 + }, + decimals: 18 + } + ] + ]) + ) + + const { getHoldingsActivity } = await import('./activity') + const response = await getHoldingsActivity(USER_ADDRESS, 'all', 10) + + expect(response.entries).toMatchObject([ + { + action: 'withdraw', + transferDirection: null, + assetAmount: '4300000', + assetAmountFormatted: 4.3, + shareAmount: '4100000000000000000', + shareAmountFormatted: 4.1 + } + ]) + }) + + it('classifies known Zapper v2 vault zap-ins as deposit zap rows', async () => { + const shareAmount = 50741940577121965627316n + const inputAmount = 100000000000000000000n + + mockZapperV2Rpc({ + transactionHash: '0xzapperv2', + transactionTo: ZAPPER_V2_ZAP_IN, + tokensRec: shareAmount, + inputAmount + }) + fetchRecentAddressScopedActivityEventsMock.mockResolvedValue({ + deposits: [], + withdrawals: [], + transfersIn: [ + createTransferEvent({ + id: 'zapper-v2-transfer-in', + vaultAddress: UNDERLYING_VAULT, + transactionHash: '0xzapperv2', + blockTimestamp: 413, + logIndex: 7, + value: shareAmount.toString(), + sender: ZAPPER_V2_ZAP_IN, + receiver: USER_ADDRESS + }) + ], + transfersOut: [], + hasMoreDeposits: false, + hasMoreWithdrawals: false, + hasMoreTransfersIn: false, + hasMoreTransfersOut: false + }) + fetchMultipleVaultsMetadataMock.mockResolvedValue( + new Map([ + [ + `1:${UNDERLYING_VAULT}`, + { + address: UNDERLYING_VAULT, + chainId: 1, + version: 'v2', + category: 'stable', + token: { + address: USDT0, + symbol: 'USDT', + decimals: 6 + }, + decimals: 18 + } + ] + ]) + ) + + const { getHoldingsActivity } = await import('./activity') + const response = await getHoldingsActivity(USER_ADDRESS, 'all', 10) + const depositResponse = await getHoldingsActivity(USER_ADDRESS, 'all', 10, 0, { type: 'deposit' }) + const transferResponse = await getHoldingsActivity(USER_ADDRESS, 'all', 10, 0, { type: 'transfer' }) + + expect(response.entries).toMatchObject([ + { + action: 'deposit', + displayType: 'zap', + transferDirection: null, + assetAmount: inputAmount.toString(), + inputTokenAddress: DAI.toLowerCase(), + inputTokenSymbol: 'DAI', + inputTokenAmount: inputAmount.toString(), + inputTokenAmountFormatted: 100, + shareAmount: shareAmount.toString(), + shareAmountFormatted: 50741.94057712197 + } + ]) + expect(depositResponse.entries).toHaveLength(1) + expect(depositResponse.entries[0]?.displayType).toBe('zap') + expect(transferResponse.entries).toEqual([]) + }) + + it('classifies known Zapper v2 vault zap-outs as withdraw zap rows', async () => { + const shareAmount = 2300000000000000000n + const outputAmount = 2500000n + + mockZapperV2ZapOutRpc({ + transactionHash: '0xzapperv2out', + shareAmount, + outputAmount + }) + fetchRecentAddressScopedActivityEventsMock.mockResolvedValue({ + deposits: [], + withdrawals: [], + transfersIn: [], + transfersOut: [ + createTransferEvent({ + id: 'zapper-v2-transfer-out', + vaultAddress: UNDERLYING_VAULT, + transactionHash: '0xzapperv2out', + blockTimestamp: 412, + logIndex: 7, + value: shareAmount.toString(), + sender: USER_ADDRESS, + receiver: ZAPPER_V2_ZAP_OUT + }) + ], + hasMoreDeposits: false, + hasMoreWithdrawals: false, + hasMoreTransfersIn: false, + hasMoreTransfersOut: false + }) + fetchMultipleVaultsMetadataMock.mockResolvedValue( + new Map([ + [ + `1:${UNDERLYING_VAULT}`, + { + address: UNDERLYING_VAULT, + chainId: 1, + version: 'v2', + category: 'stable', + token: { + address: USDT0, + symbol: 'USDT', + decimals: 6 + }, + decimals: 18 + } + ] + ]) + ) + + const { getHoldingsActivity } = await import('./activity') + const response = await getHoldingsActivity(USER_ADDRESS, 'all', 10) + const withdrawResponse = await getHoldingsActivity(USER_ADDRESS, 'all', 10, 0, { type: 'withdraw' }) + const transferResponse = await getHoldingsActivity(USER_ADDRESS, 'all', 10, 0, { type: 'transfer' }) + + expect(response.entries).toMatchObject([ + { + action: 'withdraw', + displayType: 'zap', + transferDirection: null, + assetAmount: outputAmount.toString(), + assetAmountFormatted: null, + outputTokenAddress: USDT0.toLowerCase(), + outputTokenSymbol: 'USDT', + outputTokenAmount: outputAmount.toString(), + outputTokenAmountFormatted: 2.5, + shareAmount: shareAmount.toString(), + shareAmountFormatted: 2.3 + } + ]) + expect(withdrawResponse.entries).toHaveLength(1) + expect(withdrawResponse.entries[0]?.displayType).toBe('zap') + expect(transferResponse.entries).toEqual([]) + }) + + it('requires a strict known Zapper v2 receipt shape before classifying transfer-ins as zaps', async () => { + const shareAmount = 50741940577121965627316n + + fetchRecentAddressScopedActivityEventsMock.mockResolvedValue({ + deposits: [], + withdrawals: [], + transfersIn: [ + createTransferEvent({ + id: 'unknown-zapper-transfer-in', + vaultAddress: UNDERLYING_VAULT, + transactionHash: '0xunknownzapper', + blockTimestamp: 413, + logIndex: 7, + value: shareAmount.toString(), + sender: ZAPPER_V2, + receiver: USER_ADDRESS + }), + createTransferEvent({ + id: 'wrong-sender-zapper-transfer-in', + vaultAddress: UNDERLYING_VAULT, + transactionHash: '0xwrongsenderzapper', + blockTimestamp: 412, + logIndex: 7, + value: shareAmount.toString(), + sender: ZAPPER_V2, + receiver: USER_ADDRESS + }), + createTransferEvent({ + id: 'wrong-pool-zapper-transfer-in', + vaultAddress: UNDERLYING_VAULT, + transactionHash: '0xwrongpoolzapper', + blockTimestamp: 411, + logIndex: 7, + value: shareAmount.toString(), + sender: ZAPPER_V2, + receiver: USER_ADDRESS + }), + createTransferEvent({ + id: 'wrong-amount-zapper-transfer-in', + vaultAddress: UNDERLYING_VAULT, + transactionHash: '0xwrongamountzapper', + blockTimestamp: 410, + logIndex: 7, + value: shareAmount.toString(), + sender: ZAPPER_V2, + receiver: USER_ADDRESS + }) + ], + transfersOut: [], + hasMoreDeposits: false, + hasMoreWithdrawals: false, + hasMoreTransfersIn: false, + hasMoreTransfersOut: false + }) + fetchMultipleVaultsMetadataMock.mockResolvedValue( + new Map([ + [ + `1:${UNDERLYING_VAULT}`, + { + address: UNDERLYING_VAULT, + chainId: 1, + version: 'v2', + category: 'stable', + token: { + address: USDT0, + symbol: 'USDT', + decimals: 6 + }, + decimals: 18 + } + ] + ]) + ) + process.env.VITE_RPC_URI_FOR_1 = 'https://rpc.example' + vi.stubGlobal( + 'fetch', + vi.fn(async (_url: string, init?: RequestInit) => { + const body = JSON.parse(String(init?.body ?? '{}')) as { + method?: string + params?: Array + } + const transactionHash = body.params?.[0] + const transactionTo = transactionHash === '0xunknownzapper' ? INTERMEDIARY : ZAPPER_V2 + const zapSender = + transactionHash === '0xwrongsenderzapper' ? '0x3333333333333333333333333333333333333333' : USER_ADDRESS + const zapPool = + transactionHash === '0xwrongpoolzapper' ? '0x4444444444444444444444444444444444444444' : UNDERLYING_VAULT + const tokensRec = transactionHash === '0xwrongamountzapper' ? shareAmount - 1n : shareAmount + + if (body.method === 'eth_getTransactionByHash') { + return new Response( + JSON.stringify({ + result: { + to: transactionTo, + input: '0x82650b10' + } + }) + ) + } + + if (body.method === 'eth_getTransactionReceipt') { + return new Response( + JSON.stringify({ + result: { + logs: [ + createTransferLog({ + tokenAddress: DAI, + from: USER_ADDRESS, + to: ZAPPER_V2, + value: 100000000000000000000n + }), + createZapperZapInLog({ + sender: zapSender, + pool: zapPool, + tokensRec + }) + ] + } + }) + ) + } + + return new Response(JSON.stringify({ result: null })) + }) + ) + + const { getHoldingsActivity } = await import('./activity') + const response = await getHoldingsActivity(USER_ADDRESS, 'all', 10) + + expect(response.entries.map((entry) => [entry.txHash, entry.action, entry.displayType])).toEqual([ + ['0xunknownzapper', 'transfer', undefined], + ['0xwrongsenderzapper', 'transfer', undefined], + ['0xwrongpoolzapper', 'transfer', undefined], + ['0xwrongamountzapper', 'transfer', undefined] + ]) + }) + + it('does not emit fallback transfers when the same transaction family has higher-level activity', async () => { + fetchRecentAddressScopedActivityEventsMock.mockResolvedValue({ + deposits: [ + createDepositEvent({ + id: 'deposit-with-transfer-1', + vaultAddress: UNDERLYING_VAULT, + transactionHash: '0xdeposittx', + blockTimestamp: 415, + logIndex: 3, + assets: '1000000', + shares: '1000000000000000000' + }) + ], + withdrawals: [], + transfersIn: [ + createTransferEvent({ + id: 'transfer-supporting-deposit-1', + vaultAddress: UNDERLYING_VAULT, + transactionHash: '0xdeposittx', + blockTimestamp: 415, + logIndex: 2, + value: '1000000000000000000', + sender: '0x0000000000000000000000000000000000000000', + receiver: USER_ADDRESS + }) + ], + transfersOut: [], + hasMoreDeposits: false, + hasMoreWithdrawals: false, + hasMoreTransfersIn: false, + hasMoreTransfersOut: false + }) + fetchMultipleVaultsMetadataMock.mockResolvedValue(new Map()) + + const { getHoldingsActivity } = await import('./activity') + const response = await getHoldingsActivity(USER_ADDRESS, 'all', 10) + + expect(response.entries.map((entry) => entry.action)).toEqual(['deposit']) + }) + + it('does not emit compatible asset-vault fallback transfers when another family has matching higher-level activity', async () => { + fetchRecentAddressScopedActivityEventsMock.mockResolvedValue({ + deposits: [], + withdrawals: [ + createWithdrawalEvent({ + id: 'ysybold-withdraw', + vaultAddress: YSYBOLD_VAULT, + transactionHash: '0xysyboldwithdraw', + blockTimestamp: 416, + logIndex: 3, + assets: '100000000000000000000', + shares: '94000000000000000000' + }) + ], + transfersIn: [ + createTransferEvent({ + id: 'ybold-transfer-in', + vaultAddress: YBOLD_VAULT, + transactionHash: '0xysyboldwithdraw', + blockTimestamp: 416, + logIndex: 2, + value: '100000000000000000000', + sender: YSYBOLD_VAULT, + receiver: USER_ADDRESS + }) + ], + transfersOut: [], + hasMoreDeposits: false, + hasMoreWithdrawals: false, + hasMoreTransfersIn: false, + hasMoreTransfersOut: false + }) + fetchActivityEventsByTransactionHashesMock.mockResolvedValue({ + deposits: [], + withdrawals: [ + createWithdrawalEvent({ + id: 'ysybold-withdraw', + vaultAddress: YSYBOLD_VAULT, + transactionHash: '0xysyboldwithdraw', + blockTimestamp: 416, + logIndex: 3, + assets: '100000000000000000000', + shares: '94000000000000000000' + }) + ], + transfers: [ + createTransferEvent({ + id: 'ybold-transfer-in', + vaultAddress: YBOLD_VAULT, + transactionHash: '0xysyboldwithdraw', + blockTimestamp: 416, + logIndex: 2, + value: '100000000000000000000', + sender: YSYBOLD_VAULT, + receiver: USER_ADDRESS + }) + ] + }) + fetchMultipleVaultsMetadataMock.mockResolvedValue( + new Map([ + [ + `1:${YSYBOLD_VAULT}`, + { + address: YSYBOLD_VAULT, + chainId: 1, + version: 'v3', + category: 'stable', + token: { + address: YBOLD_VAULT, + symbol: 'yBOLD', + decimals: 18 + }, + decimals: 18 + } + ], + [ + `1:${YBOLD_VAULT}`, + { + address: YBOLD_VAULT, + chainId: 1, + version: 'v3', + category: 'stable', + token: { + address: '0x6440f144b7e50d6a8439336510312d2f54beb01d', + symbol: 'BOLD', + decimals: 18 + }, + decimals: 18 + } + ] + ]) + ) + + const { getHoldingsActivity } = await import('./activity') + const response = await getHoldingsActivity(USER_ADDRESS, 'all', 10) + + expect(response.entries).toMatchObject([ + { + action: 'withdraw', + vaultAddress: YSYBOLD_VAULT, + assetSymbol: 'yBOLD', + assetAmount: '100000000000000000000', + shareAmount: '94000000000000000000' + } + ]) + expect(response.entries).toHaveLength(1) + }) + + it('filters transfer fallback rows by activity type', async () => { + fetchRecentAddressScopedActivityEventsMock.mockResolvedValue({ + deposits: [], + withdrawals: [], + transfersIn: [ + createTransferEvent({ + id: 'transfer-filter-1', + vaultAddress: UNDERLYING_VAULT, + transactionHash: '0xtransferfilter', + blockTimestamp: 420, + logIndex: 2, + value: '1000000000000000000', + sender: INTERMEDIARY, + receiver: USER_ADDRESS + }) + ], + transfersOut: [], + hasMoreDeposits: false, + hasMoreWithdrawals: false, + hasMoreTransfersIn: false, + hasMoreTransfersOut: false + }) + fetchMultipleVaultsMetadataMock.mockResolvedValue(new Map()) + + const { getHoldingsActivity } = await import('./activity') + const transferResponse = await getHoldingsActivity(USER_ADDRESS, 'all', 10, 0, { type: 'transfer' }) + const depositResponse = await getHoldingsActivity(USER_ADDRESS, 'all', 10, 0, { type: 'deposit' }) + + expect(transferResponse.entries).toHaveLength(1) + expect(transferResponse.entries[0]?.action).toBe('transfer') + expect(depositResponse.entries).toEqual([]) + }) + + it('marks known rewards distributor transfers as reward claims while keeping transfer action type', async () => { + fetchRecentAddressScopedActivityEventsMock.mockResolvedValue({ + deposits: [], + withdrawals: [], + transfersIn: [ + createTransferEvent({ + id: 'ybs-reward-claim-transfer', + vaultAddress: UNDERLYING_VAULT, + transactionHash: '0xrewardclaimybs', + blockTimestamp: 430, + logIndex: 2, + value: '1200000000000000000', + sender: YBS_REWARD_DISTRIBUTOR, + receiver: USER_ADDRESS + }), + createTransferEvent({ + id: 'yyb-reward-claim-transfer', + vaultAddress: UNDERLYING_VAULT, + transactionHash: '0xrewardclaimyyb', + blockTimestamp: 420, + logIndex: 2, + value: '3400000000000000000', + sender: YYB_REWARD_DISTRIBUTOR, + receiver: USER_ADDRESS + }) + ], + transfersOut: [], + hasMoreDeposits: false, + hasMoreWithdrawals: false, + hasMoreTransfersIn: false, + hasMoreTransfersOut: false + }) + fetchMultipleVaultsMetadataMock.mockResolvedValue( + new Map([ + [ + `1:${UNDERLYING_VAULT}`, + { + address: UNDERLYING_VAULT, + chainId: 1, + version: 'v3', + category: 'stable', + token: { + address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + symbol: 'USDC', + decimals: 6 + }, + decimals: 18 + } + ] + ]) + ) + mockRewardDistributorRpc({ + '0xrewardclaimybs': YBS_REWARD_DISTRIBUTOR, + '0xrewardclaimyyb': YYB_REWARD_DISTRIBUTOR + }) + + const { getHoldingsActivity } = await import('./activity') + const response = await getHoldingsActivity(USER_ADDRESS, 'all', 10) + + expect(response.entries.map((entry) => [entry.txHash, entry.action, entry.displayType])).toEqual([ + ['0xrewardclaimybs', 'transfer', 'reward_claim'], + ['0xrewardclaimyyb', 'transfer', 'reward_claim'] + ]) + }) + + it('collapses known yCRV zap transfer pairs to the incoming output leg with input token details', async () => { + const outputVault = '0x27b5739e22ad9033bcbf192059122d163b60349d' + + fetchRecentAddressScopedActivityEventsMock.mockResolvedValue({ + deposits: [], + withdrawals: [], + transfersIn: [ + createTransferEvent({ + id: 'ycrv-zap-transfer-in', + vaultAddress: outputVault, + transactionHash: '0xycrvzap', + blockTimestamp: 430, + logIndex: 9, + value: '17760163460645012029397', + sender: '0x0000000000000000000000000000000000000000', + receiver: USER_ADDRESS + }) + ], + transfersOut: [ + createTransferEvent({ + id: 'ycrv-zap-transfer-out', + vaultAddress: UNDERLYING_VAULT, + transactionHash: '0xycrvzap', + blockTimestamp: 430, + logIndex: 3, + value: '8913214966288657814790', + sender: USER_ADDRESS, + receiver: YCRV_ZAP + }) + ], + hasMoreDeposits: false, + hasMoreWithdrawals: false, + hasMoreTransfersIn: false, + hasMoreTransfersOut: false + }) + fetchMultipleVaultsMetadataMock.mockResolvedValue( + new Map([ + [ + `1:${outputVault}`, + { + address: outputVault, + chainId: 1, + version: 'v2', + category: 'stable', + token: { + address: outputVault, + symbol: 'st-yCRV', + decimals: 18 + }, + decimals: 18 + } + ] + ]) + ) + mockYcrvZapRpc({ + inputTokenAddress: UNDERLYING_VAULT, + outputTokenAddress: outputVault, + inputAmount: 8913214966288657814790n, + inputTokenSymbol: 'yvCurve', + inputTokenDecimals: 18 + }) + + const { getHoldingsActivity } = await import('./activity') + const response = await getHoldingsActivity(USER_ADDRESS, 'all', 10) + + expect(response.entries).toEqual([ + { + chainId: 1, + txHash: '0xycrvzap', + timestamp: 430, + action: 'transfer', + transferDirection: 'in', + vaultAddress: outputVault, + familyVaultAddress: outputVault, + assetSymbol: 'st-yCRV', + assetAmount: '0', + assetAmountFormatted: null, + inputTokenAddress: UNDERLYING_VAULT, + inputTokenSymbol: 'yvCurve', + inputTokenAmount: '8913214966288657814790', + inputTokenAmountFormatted: 8913.214966288659, + outputTokenAddress: outputVault, + outputTokenSymbol: 'yvCurve', + outputTokenAmount: null, + outputTokenAmountFormatted: null, + shareAmount: '17760163460645012029397', + shareAmountFormatted: 17760.163460645013, + status: 'ok' + } + ]) + }) + + it('classifies yCRV Boosted Staker zaps as stake rows when only the outgoing leg is address scoped', async () => { + fetchRecentAddressScopedActivityEventsMock.mockResolvedValue({ + deposits: [], + withdrawals: [], + transfersIn: [], + transfersOut: [ + createTransferEvent({ + id: 'ycrv-zap-outgoing-only', + vaultAddress: UNDERLYING_VAULT, + transactionHash: '0xycrvzapout', + blockTimestamp: 425, + logIndex: 3, + value: '3000000000000000000', + sender: USER_ADDRESS, + receiver: YCRV_ZAP + }) + ], + hasMoreDeposits: false, + hasMoreWithdrawals: false, + hasMoreTransfersIn: false, + hasMoreTransfersOut: false + }) + fetchMultipleVaultsMetadataMock.mockResolvedValue(new Map()) + mockYcrvZapRpc({ + inputTokenAddress: UNDERLYING_VAULT, + outputTokenAddress: '0xe9a115b77a1057c918f997c32663fdce24fb873f', + inputAmount: 3000000000000000000n, + inputTokenSymbol: 'yvCurve', + inputTokenDecimals: 18 + }) + + const { getHoldingsActivity } = await import('./activity') + const response = await getHoldingsActivity(USER_ADDRESS, 'all', 10) + + expect(response.entries).toMatchObject([ + { + action: 'stake', + transferDirection: null, + inputTokenAddress: UNDERLYING_VAULT, + inputTokenSymbol: 'yvCurve', + inputTokenAmount: '3000000000000000000', + inputTokenAmountFormatted: 3, + outputTokenAddress: '0xe9a115b77a1057c918f997c32663fdce24fb873f', + outputTokenSymbol: 'yCRV Boosted Staker', + outputTokenAmount: null, + outputTokenAmountFormatted: null + } + ]) + }) + + it('recovers routed withdrawals and enriches the final token received from the tx receipt', async () => { + fetchRecentAddressScopedActivityEventsMock.mockResolvedValue({ + deposits: [], + withdrawals: [], + transfersIn: [], + transfersOut: [ + createTransferEvent({ + id: 'transfer-out-1', + vaultAddress: UNDERLYING_VAULT, + transactionHash: '0xroute', + blockTimestamp: 400, + logIndex: 1, + value: '849068037733633594470', + sender: USER_ADDRESS, + receiver: INTERMEDIARY + }) + ], + hasMoreDeposits: false, + hasMoreWithdrawals: false, + hasMoreTransfersIn: false, + hasMoreTransfersOut: false + }) + fetchActivityEventsByTransactionHashesMock.mockResolvedValue({ + deposits: [], + withdrawals: [ + createWithdrawalEvent({ + id: 'withdraw-route-1', + vaultAddress: UNDERLYING_VAULT, + transactionHash: '0xroute', + blockTimestamp: 400, + logIndex: 5, + assets: '1072609', + shares: '849068037733633594470' + }) + ], + transfers: [ + createTransferEvent({ + id: 'transfer-burn-1', + vaultAddress: UNDERLYING_VAULT, + transactionHash: '0xroute', + blockTimestamp: 400, + logIndex: 4, + value: '849068037733633594470', + sender: INTERMEDIARY, + receiver: '0x0000000000000000000000000000000000000000' + }) + ] + }) + fetchMultipleVaultsMetadataMock.mockResolvedValue( + new Map([ + [ + `1:${UNDERLYING_VAULT}`, + { + address: UNDERLYING_VAULT, + chainId: 1, + version: 'v3', + category: 'stable', + token: { + address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + symbol: 'USDC', + decimals: 6 + }, + decimals: 18 + } + ] + ]) + ) + mockReceiptEnrichmentRpc({ + tokenAddress: USDT0, + tokenSymbol: 'USDT0', + tokenDecimals: 6, + logs: [ + createTransferLog({ + tokenAddress: USDT0, + from: INTERMEDIARY, + to: USER_ADDRESS, + value: 1068000n + }) + ] + }) + + const { getHoldingsActivity } = await import('./activity') + const response = await getHoldingsActivity(USER_ADDRESS, 'all', 10) + + expect(fetchActivityEventsByTransactionHashesMock).toHaveBeenCalledWith(new Map([[1, ['0xroute']]]), 'all') + expect(response.entries).toEqual([ + { + chainId: 1, + txHash: '0xroute', + timestamp: 400, + action: 'withdraw', + transferDirection: null, + vaultAddress: UNDERLYING_VAULT, + familyVaultAddress: UNDERLYING_VAULT, + assetSymbol: 'USDC', + assetAmount: '1072609', + assetAmountFormatted: null, + inputTokenAddress: null, + inputTokenSymbol: null, + inputTokenAmount: null, + inputTokenAmountFormatted: null, + outputTokenAddress: USDT0.toLowerCase(), + outputTokenSymbol: 'USDT0', + outputTokenAmount: '1068000', + outputTokenAmountFormatted: 1.068, + shareAmount: '849068037733633594470', + shareAmountFormatted: 849.0680377336336, + status: 'ok' + } + ]) + }) +}) diff --git a/api/lib/holdings/services/activity.ts b/api/lib/holdings/services/activity.ts new file mode 100644 index 000000000..8e4534fd1 --- /dev/null +++ b/api/lib/holdings/services/activity.ts @@ -0,0 +1,1463 @@ +import type { DepositEvent, TransferEvent, VaultMetadata, WithdrawEvent } from '../types' +import { + fetchDirectV2VaultActionForActivity, + fetchIsRewardClaimForActivity, + fetchRouterInputAssetForActivity, + fetchRouterOutputAssetForActivity, + fetchYcrvZapInputAssetForActivity, + fetchZapperV2ZapForActivity, + fetchZapperV2ZapOutForActivity, + type TActivityInputAsset, + type TActivityOutputAsset, + type TDirectV2VaultAction, + type TZapperV2Zap, + type TZapperV2ZapOut +} from './activityReceiptEnrichment' +import type { TransactionActivityEvents, VaultVersion } from './graphql' +import { + fetchActivityEventsByTransactionHashes, + fetchRecentAddressScopedActivityEvents, + fetchUserEvents +} from './graphql' +import { + formatAmount, + isKnownCompatibleAssetVaultRollover, + lowerCaseAddress, + minBigInt, + toVaultKey, + ZERO +} from './pnlShared' +import { getFamilyVaultAddress, isStakingVault } from './staking' +import { fetchMultipleVaultsMetadata } from './vaults' + +export type HoldingsActivityAction = 'deposit' | 'withdraw' | 'stake' | 'unstake' | 'transfer' | 'swap' +export type HoldingsActivityTypeFilter = HoldingsActivityAction | 'all' +export type HoldingsActivityDisplayType = 'reward_claim' | 'zap' + +export interface HoldingsActivityFilters { + type?: HoldingsActivityTypeFilter + chainId?: number | null + startTimestamp?: number | null + endTimestamp?: number | null +} + +export interface HoldingsActivityEntry { + chainId: number + txHash: string + timestamp: number + action: HoldingsActivityAction + displayType?: HoldingsActivityDisplayType | null + transferDirection: 'in' | 'out' | null + vaultAddress: string + familyVaultAddress: string + assetSymbol: string | null + assetAmount: string + assetAmountFormatted: number | null + inputTokenAddress: string | null + inputTokenSymbol: string | null + inputTokenAmount: string | null + inputTokenAmountFormatted: number | null + outputTokenAddress: string | null + outputTokenSymbol: string | null + outputTokenAmount: string | null + outputTokenAmountFormatted: number | null + shareAmount: string + shareAmountFormatted: number | null + status: 'ok' | 'missing_metadata' +} + +export interface HoldingsActivityResponse { + address: string + version: VaultVersion + limit: number + offset: number + facets?: { + chainIds: number[] + } + pageInfo: { + hasMore: boolean + nextOffset: number | null + } + entries: HoldingsActivityEntry[] +} + +type TActivityScopes = { + address: boolean + tx: boolean +} + +type TActivityEvent = + | { + kind: 'deposit' + id: string + chainId: number + vaultAddress: string + familyVaultAddress: string + isStakingVault: boolean + blockNumber: number + blockTimestamp: number + logIndex: number + transactionHash: string + owner: string + sender: string + assets: bigint + shares: bigint + scopes: TActivityScopes + } + | { + kind: 'withdrawal' + id: string + chainId: number + vaultAddress: string + familyVaultAddress: string + isStakingVault: boolean + blockNumber: number + blockTimestamp: number + logIndex: number + transactionHash: string + assets: bigint + shares: bigint + scopes: TActivityScopes + } + | { + kind: 'transfer' + id: string + chainId: number + vaultAddress: string + familyVaultAddress: string + isStakingVault: boolean + blockNumber: number + blockTimestamp: number + logIndex: number + transactionHash: string + sender: string + receiver: string + shares: bigint + scopes: TActivityScopes + } + +type TResolvedActivityEvent = { + chainId: number + txHash: string + timestamp: number + blockNumber: number + logIndex: number + vaultAddress: string + familyVaultAddress: string + action: HoldingsActivityAction + displayType: HoldingsActivityDisplayType | null + transferDirection: 'in' | 'out' | null + assets: bigint + shares: bigint + owner: string | null + sender: string | null + inputAsset: TActivityInputAsset | null + outputAsset: TActivityOutputAsset | null + usesRouter: boolean +} + +type TResolvedActivityEntry = readonly [string, TResolvedActivityEvent] +type TDirectV2VaultActionEntry = readonly [string, TDirectV2VaultAction] +type TZapperV2ZapEntry = readonly [string, TZapperV2Zap | TZapperV2ZapOut] + +type TRecentActivityWindow = { + candidateEvents: TActivityEvent[] + hasPotentialMore: boolean +} + +type TDepositWithdrawalSums = { + assets: bigint + shares: bigint + latestEvent: Extract | null +} + +type TNormalizedActivityFilters = { + type: HoldingsActivityTypeFilter + chainId: number | null + startTimestamp: number | null + endTimestamp: number | null +} + +const MAX_FILTERED_ACTIVITY_TRANSACTIONS = 500 +const MAX_FILTERED_ACTIVITY_ATTEMPTS = 5 +const DEFAULT_ACTIVITY_FILTERS: TNormalizedActivityFilters = { + type: 'all', + chainId: null, + startTimestamp: null, + endTimestamp: null +} + +function getSortedChainIds(events: Array<{ chainId: number }>): number[] { + return Array.from(new Set(events.map((event) => event.chainId))).sort((a, b) => a - b) +} + +export async function getHoldingsActivityFacets( + userAddress: string, + version: VaultVersion +): Promise { + const events = await fetchUserEvents(userAddress, version, undefined, 'parallel', 'paged') + + return { + chainIds: getSortedChainIds([ + ...events.deposits, + ...events.withdrawals, + ...events.transfersIn, + ...events.transfersOut + ]) + } +} + +function compareStringDesc(a: string, b: string): number { + if (a === b) { + return 0 + } + + return a > b ? -1 : 1 +} + +function compareActivityEventsDesc(a: TActivityEvent, b: TActivityEvent): number { + return ( + b.blockTimestamp - a.blockTimestamp || + b.blockNumber - a.blockNumber || + b.logIndex - a.logIndex || + b.chainId - a.chainId || + compareStringDesc(a.transactionHash, b.transactionHash) || + compareStringDesc(a.id, b.id) + ) +} + +function createScopes(scope: 'address' | 'tx'): TActivityScopes { + return { + address: scope === 'address', + tx: scope === 'tx' + } +} + +function normalizeDepositEvent(event: DepositEvent, scope: 'address' | 'tx'): TActivityEvent { + return { + kind: 'deposit', + id: event.id, + chainId: event.chainId, + vaultAddress: lowerCaseAddress(event.vaultAddress), + familyVaultAddress: getFamilyVaultAddress(event.chainId, event.vaultAddress), + isStakingVault: isStakingVault(event.chainId, event.vaultAddress), + blockNumber: event.blockNumber, + blockTimestamp: event.blockTimestamp, + logIndex: event.logIndex, + transactionHash: lowerCaseAddress(event.transactionHash), + owner: lowerCaseAddress(event.owner), + sender: lowerCaseAddress(event.sender), + assets: BigInt(event.assets), + shares: BigInt(event.shares), + scopes: createScopes(scope) + } +} + +function normalizeWithdrawalEvent(event: WithdrawEvent, scope: 'address' | 'tx'): TActivityEvent { + return { + kind: 'withdrawal', + id: event.id, + chainId: event.chainId, + vaultAddress: lowerCaseAddress(event.vaultAddress), + familyVaultAddress: getFamilyVaultAddress(event.chainId, event.vaultAddress), + isStakingVault: isStakingVault(event.chainId, event.vaultAddress), + blockNumber: event.blockNumber, + blockTimestamp: event.blockTimestamp, + logIndex: event.logIndex, + transactionHash: lowerCaseAddress(event.transactionHash), + assets: BigInt(event.assets), + shares: BigInt(event.shares), + scopes: createScopes(scope) + } +} + +function normalizeTransferEvent(event: TransferEvent, scope: 'address' | 'tx'): TActivityEvent { + return { + kind: 'transfer', + id: event.id, + chainId: event.chainId, + vaultAddress: lowerCaseAddress(event.vaultAddress), + familyVaultAddress: getFamilyVaultAddress(event.chainId, event.vaultAddress), + isStakingVault: isStakingVault(event.chainId, event.vaultAddress), + blockNumber: event.blockNumber, + blockTimestamp: event.blockTimestamp, + logIndex: event.logIndex, + transactionHash: lowerCaseAddress(event.transactionHash), + sender: lowerCaseAddress(event.sender), + receiver: lowerCaseAddress(event.receiver), + shares: BigInt(event.value), + scopes: createScopes(scope) + } +} + +function sortEventsDesc(events: TActivityEvent[]): TActivityEvent[] { + return [...events].sort(compareActivityEventsDesc) +} + +function toTxKey(event: TActivityEvent): string { + return `${event.chainId}:${event.transactionHash}` +} + +function getSelectedTransactionKeys(events: TActivityEvent[], limit: number): string[] { + return events.reduce((keys, event) => { + const txKey = toTxKey(event) + + if (keys.length >= limit || keys.includes(txKey)) { + return keys + } + + keys.push(txKey) + return keys + }, []) +} + +function buildTransactionHashesByChain(transactionKeys: string[]): Map { + return transactionKeys.reduce>((grouped, key) => { + const [chainIdRaw, txHash] = key.split(':') + const chainId = Number(chainIdRaw) + + if (!Number.isFinite(chainId) || !txHash) { + return grouped + } + + const existing = grouped.get(chainId) ?? [] + + return existing.includes(txHash) ? grouped : new Map(grouped).set(chainId, [...existing, txHash]) + }, new Map()) +} + +function emptyTransactionActivityEvents(): TransactionActivityEvents { + return { + deposits: [], + withdrawals: [], + transfers: [] + } +} + +function normalizeActivityTimestamp(timestamp: number | null | undefined): number | null { + return typeof timestamp === 'number' && Number.isInteger(timestamp) && timestamp >= 0 ? timestamp : null +} + +function normalizeActivityFilters(filters: HoldingsActivityFilters): TNormalizedActivityFilters { + const chainId = + typeof filters.chainId === 'number' && Number.isInteger(filters.chainId) && filters.chainId > 0 + ? filters.chainId + : null + const startTimestamp = normalizeActivityTimestamp(filters.startTimestamp) + const endTimestamp = normalizeActivityTimestamp(filters.endTimestamp) + const hasInvertedRange = startTimestamp !== null && endTimestamp !== null && startTimestamp > endTimestamp + + return { + type: filters.type ?? DEFAULT_ACTIVITY_FILTERS.type, + chainId, + startTimestamp: hasInvertedRange ? endTimestamp : startTimestamp, + endTimestamp: hasInvertedRange ? startTimestamp : endTimestamp + } +} + +function hasActiveActivityFilters(filters: TNormalizedActivityFilters): boolean { + return ( + filters.type !== 'all' || + filters.chainId !== null || + filters.startTimestamp !== null || + filters.endTimestamp !== null + ) +} + +function mergeActivityEvents(events: TActivityEvent[]): TActivityEvent[] { + return Array.from( + events + .reduce>((merged, event) => { + const key = `${event.kind}:${event.id}` + const existing = merged.get(key) + + if (!existing) { + return new Map(merged).set(key, event) + } + + return new Map(merged).set(key, { + ...existing, + scopes: { + address: existing.scopes.address || event.scopes.address, + tx: existing.scopes.tx || event.scopes.tx + } + }) + }, new Map()) + .values() + ) +} + +function scaleAmountByMatchedShares(totalAmount: bigint, totalShares: bigint, matchedShares: bigint): bigint { + if (totalAmount <= ZERO || totalShares <= ZERO || matchedShares <= ZERO) { + return ZERO + } + + return (totalAmount * matchedShares) / totalShares +} + +function getDepositWithdrawalSums( + events: TActivityEvent[], + predicate: (event: Extract) => boolean +): TDepositWithdrawalSums { + return events.reduce( + (totals, event) => { + if ((event.kind !== 'deposit' && event.kind !== 'withdrawal') || !predicate(event)) { + return totals + } + + return { + assets: totals.assets + event.assets, + shares: totals.shares + event.shares, + latestEvent: + totals.latestEvent === null || compareActivityEventsDesc(event, totals.latestEvent) < 0 + ? event + : totals.latestEvent + } + }, + { + assets: ZERO, + shares: ZERO, + latestEvent: null + } + ) +} + +function getTransferShareSum( + events: TActivityEvent[], + predicate: (event: Extract) => boolean +): bigint { + return events.reduce((total, event) => { + if (event.kind !== 'transfer' || !predicate(event)) { + return total + } + + return total + event.shares + }, ZERO) +} + +function createResolvedActivityEvent(args: { + action: HoldingsActivityAction + event: TActivityEvent + assetAmount: bigint + shareAmount: bigint + transferDirection?: 'in' | 'out' | null + usesRouter?: boolean +}): TResolvedActivityEvent { + return { + chainId: args.event.chainId, + txHash: args.event.transactionHash, + timestamp: args.event.blockTimestamp, + blockNumber: args.event.blockNumber, + logIndex: args.event.logIndex, + vaultAddress: args.event.vaultAddress, + familyVaultAddress: args.event.familyVaultAddress, + action: args.action, + displayType: null, + transferDirection: args.transferDirection ?? null, + assets: args.assetAmount, + shares: args.shareAmount, + owner: args.event.kind === 'deposit' ? args.event.owner : null, + sender: args.event.kind === 'deposit' ? args.event.sender : null, + inputAsset: null, + outputAsset: null, + usesRouter: args.usesRouter ?? false + } +} + +function createTransferActivityEvents(events: TActivityEvent[], userAddress: string): TResolvedActivityEvent[] { + const normalizedUserAddress = lowerCaseAddress(userAddress) + + return events + .filter( + (event): event is Extract => + event.kind === 'transfer' && + event.scopes.address && + event.shares > ZERO && + (event.receiver === normalizedUserAddress || event.sender === normalizedUserAddress) + ) + .map((event) => + createResolvedActivityEvent({ + action: 'transfer', + event, + assetAmount: ZERO, + shareAmount: event.shares, + transferDirection: event.receiver === normalizedUserAddress ? 'in' : 'out' + }) + ) +} + +function classifyTxFamilyEvents(events: TActivityEvent[], userAddress: string): TResolvedActivityEvent[] { + const normalizedUserAddress = lowerCaseAddress(userAddress) + const directDeposits = getDepositWithdrawalSums( + events, + (event) => event.kind === 'deposit' && !event.isStakingVault && event.scopes.address + ) + const directWithdrawals = getDepositWithdrawalSums( + events, + (event) => event.kind === 'withdrawal' && !event.isStakingVault && event.scopes.address + ) + const directStakes = getDepositWithdrawalSums( + events, + (event) => event.kind === 'deposit' && event.isStakingVault && event.scopes.address + ) + const directUnstakes = getDepositWithdrawalSums( + events, + (event) => event.kind === 'withdrawal' && event.isStakingVault && event.scopes.address + ) + const txDeposits = getDepositWithdrawalSums( + events, + (event) => event.kind === 'deposit' && !event.isStakingVault && event.scopes.tx + ) + const txWithdrawals = getDepositWithdrawalSums( + events, + (event) => event.kind === 'withdrawal' && !event.isStakingVault && event.scopes.tx + ) + const txStakes = getDepositWithdrawalSums( + events, + (event) => event.kind === 'deposit' && event.isStakingVault && event.scopes.tx + ) + const txUnstakes = getDepositWithdrawalSums( + events, + (event) => event.kind === 'withdrawal' && event.isStakingVault && event.scopes.tx + ) + const addressTransferInUnderlyingShares = getTransferShareSum( + events, + (event) => !event.isStakingVault && event.scopes.address && event.receiver === normalizedUserAddress + ) + const addressTransferOutUnderlyingShares = getTransferShareSum( + events, + (event) => !event.isStakingVault && event.scopes.address && event.sender === normalizedUserAddress + ) + + const classifiedEvents = [ + directDeposits.latestEvent && directDeposits.shares > ZERO + ? createResolvedActivityEvent({ + action: 'deposit', + event: directDeposits.latestEvent, + assetAmount: directDeposits.assets, + shareAmount: directDeposits.shares + }) + : txDeposits.latestEvent && txDeposits.shares > ZERO && addressTransferInUnderlyingShares > ZERO + ? (() => { + const matchedDepositShares = minBigInt(txDeposits.shares, addressTransferInUnderlyingShares) + + return matchedDepositShares > ZERO + ? createResolvedActivityEvent({ + action: 'deposit', + event: txDeposits.latestEvent, + assetAmount: scaleAmountByMatchedShares(txDeposits.assets, txDeposits.shares, matchedDepositShares), + shareAmount: matchedDepositShares, + usesRouter: true + }) + : null + })() + : null, + directWithdrawals.latestEvent && directWithdrawals.shares > ZERO + ? createResolvedActivityEvent({ + action: 'withdraw', + event: directWithdrawals.latestEvent, + assetAmount: directWithdrawals.assets, + shareAmount: directWithdrawals.shares + }) + : txWithdrawals.latestEvent && txWithdrawals.shares > ZERO && addressTransferOutUnderlyingShares > ZERO + ? (() => { + const matchedWithdrawalShares = minBigInt(txWithdrawals.shares, addressTransferOutUnderlyingShares) + + return matchedWithdrawalShares > ZERO + ? createResolvedActivityEvent({ + action: 'withdraw', + event: txWithdrawals.latestEvent, + assetAmount: scaleAmountByMatchedShares( + txWithdrawals.assets, + txWithdrawals.shares, + matchedWithdrawalShares + ), + shareAmount: matchedWithdrawalShares, + usesRouter: true + }) + : null + })() + : null, + directStakes.latestEvent && directStakes.assets > ZERO + ? createResolvedActivityEvent({ + action: 'stake', + event: directStakes.latestEvent, + assetAmount: directStakes.assets, + shareAmount: directStakes.shares + }) + : txStakes.latestEvent && txStakes.assets > ZERO && addressTransferOutUnderlyingShares > ZERO + ? (() => { + const matchedStakeAssets = minBigInt(txStakes.assets, addressTransferOutUnderlyingShares) + + return matchedStakeAssets > ZERO + ? createResolvedActivityEvent({ + action: 'stake', + event: txStakes.latestEvent, + assetAmount: matchedStakeAssets, + shareAmount: scaleAmountByMatchedShares(txStakes.shares, txStakes.assets, matchedStakeAssets), + usesRouter: true + }) + : null + })() + : null, + directUnstakes.latestEvent && directUnstakes.assets > ZERO + ? createResolvedActivityEvent({ + action: 'unstake', + event: directUnstakes.latestEvent, + assetAmount: directUnstakes.assets, + shareAmount: directUnstakes.shares + }) + : txUnstakes.latestEvent && txUnstakes.assets > ZERO && addressTransferInUnderlyingShares > ZERO + ? (() => { + const matchedUnstakeAssets = minBigInt(txUnstakes.assets, addressTransferInUnderlyingShares) + + return matchedUnstakeAssets > ZERO + ? createResolvedActivityEvent({ + action: 'unstake', + event: txUnstakes.latestEvent, + assetAmount: matchedUnstakeAssets, + shareAmount: scaleAmountByMatchedShares(txUnstakes.shares, txUnstakes.assets, matchedUnstakeAssets), + usesRouter: true + }) + : null + })() + : null + ].filter((event): event is TResolvedActivityEvent => event !== null) + + return classifiedEvents.length > 0 ? classifiedEvents : createTransferActivityEvents(events, userAddress) +} + +function classifyActivityEvents(events: TActivityEvent[], userAddress: string): TResolvedActivityEvent[] { + return Array.from( + events.reduce>((grouped, event) => { + const key = `${toTxKey(event)}:${event.familyVaultAddress}` + const existing = grouped.get(key) ?? [] + return new Map(grouped).set(key, [...existing, event]) + }, new Map()) + ) + .flatMap(([, txFamilyEvents]) => classifyTxFamilyEvents(txFamilyEvents, userAddress)) + .sort((a, b) => b.timestamp - a.timestamp || b.blockNumber - a.blockNumber || b.logIndex - a.logIndex) +} + +function isCompatibleAssetVaultFallbackTransfer(args: { + highLevelEvent: TResolvedActivityEvent + transferEvent: TResolvedActivityEvent +}): boolean { + if ( + args.transferEvent.action !== 'transfer' || + args.transferEvent.transferDirection === null || + args.highLevelEvent.chainId !== args.transferEvent.chainId || + args.highLevelEvent.txHash !== args.transferEvent.txHash + ) { + return false + } + + const isCompatiblePair = isKnownCompatibleAssetVaultRollover( + args.highLevelEvent.chainId, + args.highLevelEvent.familyVaultAddress, + args.transferEvent.familyVaultAddress + ) + + if (!isCompatiblePair || args.highLevelEvent.assets !== args.transferEvent.shares) { + return false + } + + return ( + (args.highLevelEvent.action === 'withdraw' && args.transferEvent.transferDirection === 'in') || + (args.highLevelEvent.action === 'deposit' && args.transferEvent.transferDirection === 'out') + ) +} + +function suppressCompatibleAssetVaultFallbackTransfers(events: TResolvedActivityEvent[]): TResolvedActivityEvent[] { + const highLevelEvents = events.filter((event) => event.action !== 'transfer') + + if (highLevelEvents.length === 0) { + return events + } + + return events.filter( + (event) => + !highLevelEvents.some((highLevelEvent) => + isCompatibleAssetVaultFallbackTransfer({ + highLevelEvent, + transferEvent: event + }) + ) + ) +} + +function createSwapActivityEvent(args: { + sourceEvent: TResolvedActivityEvent + destinationEvent: TResolvedActivityEvent +}): TResolvedActivityEvent { + return { + ...args.destinationEvent, + action: 'swap', + displayType: null, + transferDirection: null, + assets: ZERO, + inputAsset: { + tokenAddress: args.sourceEvent.vaultAddress, + tokenSymbol: null, + amount: args.sourceEvent.shares.toString(), + amountFormatted: null + }, + outputAsset: null, + usesRouter: true + } +} + +function isSwapSourceEvent(event: TResolvedActivityEvent): boolean { + return event.action === 'withdraw' && event.shares > ZERO +} + +function isSwapDestinationEvent(event: TResolvedActivityEvent): boolean { + return event.action === 'deposit' && event.shares > ZERO +} + +function collapseRouterVaultSwaps(events: TResolvedActivityEvent[]): TResolvedActivityEvent[] { + const swapsByTransactionKey = new Map( + Array.from( + events.reduce>((grouped, event) => { + const existing = grouped.get(toResolvedTxKey(event)) ?? [] + return new Map(grouped).set(toResolvedTxKey(event), [...existing, event]) + }, new Map()) + ) + .map(([txKey, txEvents]) => { + const sourceEvents = txEvents.filter(isSwapSourceEvent) + const destinationEvents = txEvents.filter(isSwapDestinationEvent) + const sourceEvent = sourceEvents.length === 1 ? sourceEvents[0] : null + const destinationEvent = destinationEvents.length === 1 ? destinationEvents[0] : null + + return sourceEvent && destinationEvent && sourceEvent.vaultAddress !== destinationEvent.vaultAddress + ? ([ + txKey, + { + sourceEvent, + destinationEvent, + swapEvent: createSwapActivityEvent({ sourceEvent, destinationEvent }) + } + ] as const) + : null + }) + .filter( + ( + entry + ): entry is readonly [ + string, + { + sourceEvent: TResolvedActivityEvent + destinationEvent: TResolvedActivityEvent + swapEvent: TResolvedActivityEvent + } + ] => entry !== null + ) + ) + + if (swapsByTransactionKey.size === 0) { + return events + } + + const emittedSwapTransactionKeys = new Set() + + return events.flatMap((event) => { + const txKey = toResolvedTxKey(event) + const swap = swapsByTransactionKey.get(txKey) + + if (!swap) { + return [event] + } + + if (event === swap.sourceEvent || event === swap.destinationEvent) { + if (emittedSwapTransactionKeys.has(txKey)) { + return [] + } + + emittedSwapTransactionKeys.add(txKey) + return [swap.swapEvent] + } + + return [event] + }) +} + +function toResolvedTxKey(event: TResolvedActivityEvent): string { + return `${event.chainId}:${event.txHash}` +} + +async function loadRecentActivityWindowAttempt( + userAddress: string, + version: VaultVersion, + targetTransactionCount: number, + limitPerSource: number, + attempt: number, + maxTimestamp?: number +): Promise { + const recentEvents = + maxTimestamp === undefined + ? await fetchRecentAddressScopedActivityEvents(userAddress, version, limitPerSource) + : await fetchRecentAddressScopedActivityEvents(userAddress, version, limitPerSource, maxTimestamp) + const candidateEvents = sortEventsDesc([ + ...recentEvents.deposits.map((event) => normalizeDepositEvent(event, 'address')), + ...recentEvents.withdrawals.map((event) => normalizeWithdrawalEvent(event, 'address')), + ...recentEvents.transfersIn.map((event) => normalizeTransferEvent(event, 'address')), + ...recentEvents.transfersOut.map((event) => normalizeTransferEvent(event, 'address')) + ]) + const hasPotentialMore = + recentEvents.hasMoreDeposits || + recentEvents.hasMoreWithdrawals || + recentEvents.hasMoreTransfersIn || + recentEvents.hasMoreTransfersOut + + return getSelectedTransactionKeys(candidateEvents, targetTransactionCount).length >= targetTransactionCount || + !hasPotentialMore || + attempt >= 5 + ? { + candidateEvents, + hasPotentialMore + } + : loadRecentActivityWindowAttempt( + userAddress, + version, + targetTransactionCount, + Math.min(limitPerSource * 2, 1000), + attempt + 1, + maxTimestamp + ) +} + +async function loadRecentActivityWindow( + userAddress: string, + version: VaultVersion, + targetTransactionCount: number, + maxTimestamp?: number +): Promise { + return loadRecentActivityWindowAttempt( + userAddress, + version, + targetTransactionCount, + Math.max(targetTransactionCount * 4, 20), + 0, + maxTimestamp + ) +} + +function normalizeTransactionActivityEvents(transactionEvents: TransactionActivityEvents): TActivityEvent[] { + return [ + ...transactionEvents.deposits.map((event) => normalizeDepositEvent(event, 'tx')), + ...transactionEvents.withdrawals.map((event) => normalizeWithdrawalEvent(event, 'tx')), + ...transactionEvents.transfers.map((event) => normalizeTransferEvent(event, 'tx')) + ] +} + +function selectYcrvZapTransferDisplayEvent(events: TResolvedActivityEvent[]): TResolvedActivityEvent | null { + const incomingTransfers = events + .filter((event) => event.action === 'transfer' && event.transferDirection === 'in') + .sort((a, b) => b.logIndex - a.logIndex) + const outgoingTransfers = events + .filter((event) => event.action === 'transfer' && event.transferDirection === 'out') + .sort((a, b) => b.logIndex - a.logIndex) + + return incomingTransfers[0] ?? outgoingTransfers[0] ?? null +} + +async function enrichYcrvZapTransferEvents( + events: TResolvedActivityEvent[], + userAddress: string +): Promise { + const highLevelTransactionKeys = new Set( + events.filter((event) => event.action !== 'transfer').map((event) => toResolvedTxKey(event)) + ) + const transferEventsByTransactionKey = events.reduce>((grouped, event) => { + if (event.action !== 'transfer' || highLevelTransactionKeys.has(toResolvedTxKey(event))) { + return grouped + } + + const txKey = toResolvedTxKey(event) + const existing = grouped.get(txKey) ?? [] + return new Map(grouped).set(txKey, [...existing, event]) + }, new Map()) + + if (transferEventsByTransactionKey.size === 0) { + return events + } + + const ycrvZapEventEntries = ( + await Promise.all( + Array.from(transferEventsByTransactionKey.entries()).map( + async ([txKey, txEvents]): Promise => { + const representativeEvent = txEvents[0] + + if (!representativeEvent) { + return null + } + + const zapAssets = await fetchYcrvZapInputAssetForActivity({ + chainId: representativeEvent.chainId, + transactionHash: representativeEvent.txHash, + userAddress, + excludedTokenAddresses: Array.from( + new Set(txEvents.flatMap((event) => [event.vaultAddress, event.familyVaultAddress]).map(lowerCaseAddress)) + ) + }) + const displayEvent = zapAssets ? selectYcrvZapTransferDisplayEvent(txEvents) : null + + if (!displayEvent || !zapAssets) { + return null + } + + return [ + txKey, + { + ...displayEvent, + action: zapAssets.outputKind === 'stake' ? 'stake' : displayEvent.action, + transferDirection: zapAssets.outputKind === 'stake' ? null : displayEvent.transferDirection, + inputAsset: zapAssets.inputAsset, + outputAsset: zapAssets.outputAsset + } + ] as const + } + ) + ) + ).filter((entry): entry is TResolvedActivityEntry => entry !== null) + const ycrvZapEventsByTransactionKey = new Map(ycrvZapEventEntries) + + if (ycrvZapEventsByTransactionKey.size === 0) { + return events + } + + const emittedYcrvZapTransactionKeys = new Set() + + return events.flatMap((event) => { + const txKey = toResolvedTxKey(event) + const ycrvZapEvent = ycrvZapEventsByTransactionKey.get(txKey) + + if (!ycrvZapEvent) { + return [event] + } + + if (event.action !== 'transfer') { + return [event] + } + + if (emittedYcrvZapTransactionKeys.has(txKey)) { + return [] + } + + emittedYcrvZapTransactionKeys.add(txKey) + return [ycrvZapEvent] + }) +} + +async function enrichDirectV2VaultTransferEvents( + events: TResolvedActivityEvent[], + userAddress: string +): Promise { + const transferEvents = events.filter( + (event) => + event.action === 'transfer' && + event.transferDirection !== null && + event.assets === ZERO && + event.shares > ZERO && + event.vaultAddress === event.familyVaultAddress + ) + + if (transferEvents.length === 0) { + return events + } + + const directActionEntries = ( + await Promise.all( + transferEvents.map(async (event): Promise => { + const directAction = await fetchDirectV2VaultActionForActivity({ + chainId: event.chainId, + transactionHash: event.txHash, + userAddress, + vaultAddress: event.vaultAddress, + transferDirection: event.transferDirection as 'in' | 'out' + }) + + return directAction + ? ([ + `${toResolvedTxKey(event)}:${event.vaultAddress}:${event.transferDirection}:${event.logIndex}`, + directAction + ] as const) + : null + }) + ) + ).filter((entry): entry is TDirectV2VaultActionEntry => entry !== null) + const directActionsByEventKey = new Map(directActionEntries) + + return directActionsByEventKey.size === 0 + ? events + : events.map((event) => { + const directAction = directActionsByEventKey.get( + `${toResolvedTxKey(event)}:${event.vaultAddress}:${event.transferDirection}:${event.logIndex}` + ) + + return directAction + ? { + ...event, + action: directAction.action, + transferDirection: null, + assets: directAction.assetAmount + } + : event + }) +} + +async function enrichZapperV2TransferEvents( + events: TResolvedActivityEvent[], + userAddress: string +): Promise { + const transferEvents = events.filter( + (event) => + event.action === 'transfer' && event.transferDirection !== null && event.assets === ZERO && event.shares > ZERO + ) + + if (transferEvents.length === 0) { + return events + } + + const zapEntries = ( + await Promise.all( + transferEvents.map(async (event): Promise => { + const zap = + event.transferDirection === 'in' + ? await fetchZapperV2ZapForActivity({ + chainId: event.chainId, + transactionHash: event.txHash, + userAddress, + vaultAddress: event.vaultAddress, + shareAmount: event.shares, + excludedTokenAddresses: [event.vaultAddress, event.familyVaultAddress] + }) + : await fetchZapperV2ZapOutForActivity({ + chainId: event.chainId, + transactionHash: event.txHash, + userAddress, + vaultAddress: event.vaultAddress, + shareAmount: event.shares + }) + + return zap + ? ([ + `${toResolvedTxKey(event)}:${event.vaultAddress}:${event.transferDirection}:${event.logIndex}`, + zap + ] as const) + : null + }) + ) + ).filter((entry): entry is TZapperV2ZapEntry => entry !== null) + const zapsByEventKey = new Map(zapEntries) + + return zapsByEventKey.size === 0 + ? events + : events.map((event) => { + const zap = zapsByEventKey.get( + `${toResolvedTxKey(event)}:${event.vaultAddress}:${event.transferDirection}:${event.logIndex}` + ) + + if (!zap) { + return event + } + + return event.transferDirection === 'out' + ? { + ...event, + action: 'withdraw', + displayType: 'zap', + transferDirection: null, + assets: zap.assetAmount, + outputAsset: 'outputAsset' in zap ? zap.outputAsset : null + } + : { + ...event, + action: 'deposit', + displayType: 'zap', + transferDirection: null, + assets: zap.assetAmount, + inputAsset: 'inputAsset' in zap ? zap.inputAsset : null + } + }) +} + +async function enrichRewardClaimTransferEvents(events: TResolvedActivityEvent[]): Promise { + const transferEventsByTransactionKey = events.reduce>((grouped, event) => { + if (event.action !== 'transfer') { + return grouped + } + + const txKey = toResolvedTxKey(event) + const existing = grouped.get(txKey) ?? [] + return new Map(grouped).set(txKey, [...existing, event]) + }, new Map()) + + if (transferEventsByTransactionKey.size === 0) { + return events + } + + const rewardClaimTransactionKeys = new Set( + ( + await Promise.all( + Array.from(transferEventsByTransactionKey.entries()).map(async ([txKey, txEvents]) => { + const representativeEvent = txEvents[0] + + if (!representativeEvent) { + return null + } + + const isRewardClaim = await fetchIsRewardClaimForActivity({ + chainId: representativeEvent.chainId, + transactionHash: representativeEvent.txHash + }) + + return isRewardClaim ? txKey : null + }) + ) + ).filter((txKey): txKey is string => txKey !== null) + ) + + return rewardClaimTransactionKeys.size === 0 + ? events + : events.map((event) => + event.action === 'transfer' && rewardClaimTransactionKeys.has(toResolvedTxKey(event)) + ? { ...event, displayType: 'reward_claim' } + : event + ) +} + +function shouldFetchInputAsset(event: TResolvedActivityEvent, userAddress: string): boolean { + const normalizedUserAddress = lowerCaseAddress(userAddress) + + return ( + event.action === 'deposit' && + event.owner === normalizedUserAddress && + event.sender !== null && + event.sender !== event.owner + ) +} + +function shouldFetchOutputAsset(event: TResolvedActivityEvent): boolean { + return event.usesRouter && (event.action === 'withdraw' || event.action === 'unstake') +} + +async function enrichActivityInputAssets( + events: TResolvedActivityEvent[], + userAddress: string, + metadata: Map +): Promise { + return Promise.all( + events.map(async (event) => { + const shouldFetchInput = shouldFetchInputAsset(event, userAddress) + const shouldFetchOutput = shouldFetchOutputAsset(event) + + if (!shouldFetchInput && !shouldFetchOutput) { + return event + } + + const eventMetadata = metadata.get(toVaultKey(event.chainId, event.vaultAddress)) ?? null + const excludedTokenAddresses = [ + event.vaultAddress, + event.familyVaultAddress, + ...(eventMetadata ? [eventMetadata.token.address] : []) + ] + const [inputAsset, outputAsset] = await Promise.all([ + shouldFetchInput + ? fetchRouterInputAssetForActivity({ + chainId: event.chainId, + transactionHash: event.txHash, + userAddress, + excludedTokenAddresses + }) + : Promise.resolve(null), + shouldFetchOutput + ? fetchRouterOutputAssetForActivity({ + chainId: event.chainId, + transactionHash: event.txHash, + userAddress, + excludedTokenAddresses + }) + : Promise.resolve(null) + ]) + + return inputAsset || outputAsset ? { ...event, inputAsset, outputAsset } : event + }) + ) +} + +function matchesActivityFilters(event: TResolvedActivityEvent, filters: TNormalizedActivityFilters): boolean { + if (filters.type !== 'all' && event.action !== filters.type) { + return false + } + + if (filters.chainId !== null && event.chainId !== filters.chainId) { + return false + } + + if (filters.startTimestamp !== null && event.timestamp < filters.startTimestamp) { + return false + } + + if (filters.endTimestamp !== null && event.timestamp > filters.endTimestamp) { + return false + } + + return true +} + +function matchesCoarseActivityFilters(event: TActivityEvent, filters: TNormalizedActivityFilters): boolean { + if (filters.chainId !== null && event.chainId !== filters.chainId) { + return false + } + + if (filters.startTimestamp !== null && event.blockTimestamp < filters.startTimestamp) { + return false + } + + if (filters.endTimestamp !== null && event.blockTimestamp > filters.endTimestamp) { + return false + } + + return true +} + +async function classifyActivityForTransactionKeys( + userAddress: string, + version: VaultVersion, + candidateEvents: TActivityEvent[], + transactionKeys: string[] +): Promise { + const selectedTransactionKeySet = new Set(transactionKeys) + const selectedAddressEvents = candidateEvents.filter((event) => selectedTransactionKeySet.has(toTxKey(event))) + const transactionEvents = + transactionKeys.length > 0 + ? await fetchActivityEventsByTransactionHashes(buildTransactionHashesByChain(transactionKeys), version) + : emptyTransactionActivityEvents() + const selectedEvents = mergeActivityEvents([ + ...selectedAddressEvents, + ...normalizeTransactionActivityEvents(transactionEvents) + ]) + const classifiedEvents = suppressCompatibleAssetVaultFallbackTransfers( + classifyActivityEvents(selectedEvents, userAddress) + ) + const directV2EnrichedEvents = await enrichDirectV2VaultTransferEvents(classifiedEvents, userAddress) + const zapperV2EnrichedEvents = await enrichZapperV2TransferEvents(directV2EnrichedEvents, userAddress) + const ycrvZapEnrichedEvents = await enrichYcrvZapTransferEvents(zapperV2EnrichedEvents, userAddress) + const swapEnrichedEvents = collapseRouterVaultSwaps(ycrvZapEnrichedEvents) + + return enrichRewardClaimTransferEvents(swapEnrichedEvents) +} + +function getVaultIdentifiers(events: TResolvedActivityEvent[]): Array<{ chainId: number; vaultAddress: string }> { + return events + .flatMap((event) => [ + { chainId: event.chainId, vaultAddress: event.vaultAddress }, + ...(event.action === 'swap' && event.inputAsset + ? [{ chainId: event.chainId, vaultAddress: event.inputAsset.tokenAddress }] + : []) + ]) + .reduce>((identifiers, identifier) => { + const alreadyIncluded = identifiers.some( + (existing) => existing.chainId === identifier.chainId && existing.vaultAddress === identifier.vaultAddress + ) + + if (!alreadyIncluded) { + identifiers.push(identifier) + } + + return identifiers + }, []) +} + +function filterVisibleActivityEvents( + events: TResolvedActivityEvent[], + metadata: Map +): TResolvedActivityEvent[] { + return events.filter((event) => !metadata.get(toVaultKey(event.chainId, event.vaultAddress))?.isHidden) +} + +function toHoldingsActivityEntry( + event: TResolvedActivityEvent, + metadata: Map +): HoldingsActivityEntry { + const eventMetadata = metadata.get(toVaultKey(event.chainId, event.vaultAddress)) ?? null + const inputVaultMetadata = + event.inputAsset && event.action === 'swap' + ? (metadata.get(toVaultKey(event.chainId, event.inputAsset.tokenAddress)) ?? null) + : null + const inputTokenSymbol = event.inputAsset?.tokenSymbol ?? null + const inputTokenAmountFormatted = + inputVaultMetadata && event.inputAsset + ? formatAmount(BigInt(event.inputAsset.amount), inputVaultMetadata.decimals) + : (event.inputAsset?.amountFormatted ?? null) + + return { + chainId: event.chainId, + txHash: event.txHash, + timestamp: event.timestamp, + action: event.action, + ...(event.displayType ? { displayType: event.displayType } : {}), + transferDirection: event.transferDirection, + vaultAddress: event.vaultAddress, + familyVaultAddress: event.familyVaultAddress, + assetSymbol: eventMetadata?.token.symbol ?? null, + assetAmount: event.assets.toString(), + assetAmountFormatted: + event.action === 'transfer' || event.action === 'swap' || event.outputAsset || !eventMetadata + ? null + : formatAmount(event.assets, eventMetadata.token.decimals), + inputTokenAddress: event.inputAsset?.tokenAddress ?? null, + inputTokenSymbol, + inputTokenAmount: event.inputAsset?.amount ?? null, + inputTokenAmountFormatted, + outputTokenAddress: event.outputAsset?.tokenAddress ?? null, + outputTokenSymbol: event.outputAsset?.tokenSymbol ?? null, + outputTokenAmount: event.outputAsset?.amount ?? null, + outputTokenAmountFormatted: event.outputAsset?.amountFormatted ?? null, + shareAmount: event.shares.toString(), + shareAmountFormatted: eventMetadata ? formatAmount(event.shares, eventMetadata.decimals) : null, + status: eventMetadata ? 'ok' : 'missing_metadata' + } +} + +async function buildActivityEntries( + userAddress: string, + events: TResolvedActivityEvent[] +): Promise<{ entries: HoldingsActivityEntry[]; metadata: Map }> { + const vaultIdentifiers = getVaultIdentifiers(events) + const metadata = vaultIdentifiers.length > 0 ? await fetchMultipleVaultsMetadata(vaultIdentifiers) : new Map() + const visibleEvents = filterVisibleActivityEvents(events, metadata) + const enrichedEvents = await enrichActivityInputAssets(visibleEvents, userAddress, metadata) + + return { + entries: enrichedEvents.map((event) => toHoldingsActivityEntry(event, metadata)), + metadata + } +} + +async function getUnfilteredHoldingsActivity( + userAddress: string, + version: VaultVersion, + boundedLimit: number, + boundedOffset: number +): Promise> { + const targetTransactionCount = boundedOffset + boundedLimit + 1 + const { candidateEvents, hasPotentialMore } = await loadRecentActivityWindow( + userAddress, + version, + targetTransactionCount + ) + const selectedTransactionKeys = getSelectedTransactionKeys(candidateEvents, targetTransactionCount) + const pageTransactionKeys = selectedTransactionKeys.slice(boundedOffset, boundedOffset + boundedLimit) + const classifiedEvents = await classifyActivityForTransactionKeys( + userAddress, + version, + candidateEvents, + pageTransactionKeys + ) + const { entries } = await buildActivityEntries(userAddress, classifiedEvents) + const hasMore = + selectedTransactionKeys.length > boundedOffset + boundedLimit || + (pageTransactionKeys.length === boundedLimit && + selectedTransactionKeys.length < targetTransactionCount && + hasPotentialMore) + + return { + entries, + pageInfo: { + hasMore, + nextOffset: hasMore ? boundedOffset + boundedLimit : null + } + } +} + +async function getFilteredHoldingsActivity( + userAddress: string, + version: VaultVersion, + boundedLimit: number, + boundedOffset: number, + filters: TNormalizedActivityFilters +): Promise> { + const requestedEntryCount = boundedOffset + boundedLimit + 1 + let targetTransactionCount = Math.min(Math.max(requestedEntryCount * 4, 20), MAX_FILTERED_ACTIVITY_TRANSACTIONS) + let attempt = 0 + let filteredEvents: TResolvedActivityEvent[] = [] + let metadata = new Map() + let hasUnscannedTransactions = false + const maxTimestamp = filters.endTimestamp ?? undefined + + while (attempt < MAX_FILTERED_ACTIVITY_ATTEMPTS) { + const { candidateEvents, hasPotentialMore } = await loadRecentActivityWindow( + userAddress, + version, + targetTransactionCount, + maxTimestamp + ) + const filteredCandidateEvents = candidateEvents.filter((event) => matchesCoarseActivityFilters(event, filters)) + const selectedTransactionKeys = getSelectedTransactionKeys(filteredCandidateEvents, targetTransactionCount) + const classifiedEvents = await classifyActivityForTransactionKeys( + userAddress, + version, + candidateEvents, + selectedTransactionKeys + ) + const matchingEvents = classifiedEvents.filter((event) => matchesActivityFilters(event, filters)) + const vaultIdentifiers = getVaultIdentifiers(matchingEvents) + metadata = vaultIdentifiers.length > 0 ? await fetchMultipleVaultsMetadata(vaultIdentifiers) : new Map() + filteredEvents = filterVisibleActivityEvents(matchingEvents, metadata) + hasUnscannedTransactions = hasPotentialMore + + if ( + filteredEvents.length >= requestedEntryCount || + !hasUnscannedTransactions || + targetTransactionCount >= MAX_FILTERED_ACTIVITY_TRANSACTIONS + ) { + break + } + + targetTransactionCount = Math.min(targetTransactionCount * 2, MAX_FILTERED_ACTIVITY_TRANSACTIONS) + attempt += 1 + } + + const pageEvents = filteredEvents.slice(boundedOffset, boundedOffset + boundedLimit) + const enrichedEvents = await enrichActivityInputAssets(pageEvents, userAddress, metadata) + const entries = enrichedEvents.map((event) => toHoldingsActivityEntry(event, metadata)) + const hasMore = + filteredEvents.length > boundedOffset + boundedLimit || + (pageEvents.length === boundedLimit && hasUnscannedTransactions) + + return { + entries, + pageInfo: { + hasMore, + nextOffset: hasMore ? boundedOffset + boundedLimit : null + } + } +} + +export async function getHoldingsActivity( + userAddress: string, + version: VaultVersion = 'all', + limit = 10, + offset = 0, + filters: HoldingsActivityFilters = DEFAULT_ACTIVITY_FILTERS, + includeFacets = false +): Promise { + const boundedLimit = Math.max(1, limit) + const boundedOffset = Math.max(0, offset) + const normalizedFilters = normalizeActivityFilters(filters) + const activityPage = !hasActiveActivityFilters(normalizedFilters) + ? await getUnfilteredHoldingsActivity(userAddress, version, boundedLimit, boundedOffset) + : await getFilteredHoldingsActivity(userAddress, version, boundedLimit, boundedOffset, normalizedFilters) + + return { + address: lowerCaseAddress(userAddress), + version, + limit: boundedLimit, + offset: boundedOffset, + ...(includeFacets ? { facets: { chainIds: getSortedChainIds(activityPage.entries) } } : {}), + pageInfo: activityPage.pageInfo, + entries: activityPage.entries + } +} diff --git a/api/lib/holdings/services/activityReceiptEnrichment.ts b/api/lib/holdings/services/activityReceiptEnrichment.ts new file mode 100644 index 000000000..df779e833 --- /dev/null +++ b/api/lib/holdings/services/activityReceiptEnrichment.ts @@ -0,0 +1,1047 @@ +import { + decodeEventLog, + decodeFunctionData, + decodeFunctionResult, + encodeFunctionData, + erc20Abi, + type Hex, + hexToString, + parseAbiItem +} from 'viem' +import { debugError } from './debug' +import { formatAmount, lowerCaseAddress, ZERO } from './pnlShared' + +const TRANSFER_EVENT = parseAbiItem('event Transfer(address indexed from, address indexed to, uint256 value)') +const ZAPPER_ZAP_IN_EVENT = parseAbiItem('event zapIn(address sender, address pool, uint256 tokensRec)') +const ZAPPER_ZAP_OUT_EVENT = parseAbiItem( + 'event zapOut(address sender, address pool, address token, uint256 tokensRec)' +) +const V2_VAULT_ACTIVITY_ABI = [ + { + stateMutability: 'nonpayable', + type: 'function', + name: 'deposit', + inputs: [{ name: '_amount', type: 'uint256' }], + outputs: [{ name: '', type: 'uint256' }] + }, + { + stateMutability: 'nonpayable', + type: 'function', + name: 'withdraw', + inputs: [{ name: '_maxShares', type: 'uint256' }], + outputs: [{ name: '', type: 'uint256' }] + } +] as const +const YCRV_ZAP_ABI = [ + { + stateMutability: 'nonpayable', + type: 'function', + name: 'zap', + inputs: [ + { name: '_input_token', type: 'address' }, + { name: '_output_token', type: 'address' } + ], + outputs: [{ name: '', type: 'uint256' }] + }, + { + stateMutability: 'nonpayable', + type: 'function', + name: 'zap', + inputs: [ + { name: '_input_token', type: 'address' }, + { name: '_output_token', type: 'address' }, + { name: '_amount_in', type: 'uint256' } + ], + outputs: [{ name: '', type: 'uint256' }] + }, + { + stateMutability: 'nonpayable', + type: 'function', + name: 'zap', + inputs: [ + { name: '_input_token', type: 'address' }, + { name: '_output_token', type: 'address' }, + { name: '_amount_in', type: 'uint256' }, + { name: '_min_out', type: 'uint256' } + ], + outputs: [{ name: '', type: 'uint256' }] + }, + { + stateMutability: 'nonpayable', + type: 'function', + name: 'zap', + inputs: [ + { name: '_input_token', type: 'address' }, + { name: '_output_token', type: 'address' }, + { name: '_amount_in', type: 'uint256' }, + { name: '_min_out', type: 'uint256' }, + { name: '_recipient', type: 'address' } + ], + outputs: [{ name: '', type: 'uint256' }] + } +] as const +const KNOWN_YCRV_ZAP_CONTRACTS_BY_CHAIN = new Map([ + [ + 1, + new Set([ + lowerCaseAddress('0x78ada385b15d89a9b845d2cac0698663f0c69e3c'), + lowerCaseAddress('0xdc899AB992fbCFbac936CE5a5bC5A86a5d35A66a') + ]) + ] +]) +const KNOWN_REWARD_DISTRIBUTOR_CONTRACTS_BY_CHAIN = new Map([ + [ + 1, + new Set([ + lowerCaseAddress('0xB226c52EB411326CdB54824a88aBaFDAAfF16D3d'), + lowerCaseAddress('0x1d02F6A86Ed5650f93E40FCD62fa5727c32ad746') + ]) + ] +]) +const KNOWN_ZAPPER_V2_CONTRACTS_BY_CHAIN = new Map([ + [ + 1, + new Set([ + lowerCaseAddress('0x42D4e90Ff4068Abe7BC4EaB838c7dE1D2F5998A3'), + lowerCaseAddress('0x462991D18666c578F787e9eC0A74Cd18D2971E5F'), + lowerCaseAddress('0xB0880df8420974ef1b040111e5e0e95f05F8fee1'), + lowerCaseAddress('0x92Be6ADB6a12Da0CA607F9d87DB2F9978cD6ec3E'), + lowerCaseAddress('0x9c57618bfCDfaE4cE8e49226Ca22A7837DE64A2d'), + lowerCaseAddress('0xd6b88257e91e4E4D4E990B3A858c849EF2DFdE8c') + ]) + ] +]) +const DEFAULT_TIMEOUT_MS = 4_000 +const DEFAULT_MAX_RETRIES = 1 + +type TRpcReceiptLog = { + address: string + data: string + topics: string[] +} +type TDecodeTopics = [] | [signature: Hex, ...args: Hex[]] + +type TRpcTransactionReceipt = { + logs: TRpcReceiptLog[] | null +} + +type TRpcTransaction = { + to: string | null + input: string | null +} + +type TDecodedTransfer = { + tokenAddress: string + from: string + to: string + value: bigint +} + +type TTokenMetadata = { + symbol: string | null + decimals: number | null +} + +export type TActivityTransferAsset = { + tokenAddress: string + tokenSymbol: string | null + amount: string + amountFormatted: number | null +} + +export type TActivityInputAsset = TActivityTransferAsset + +export type TActivityOutputAsset = { + tokenAddress: string + tokenSymbol: string | null + amount: string | null + amountFormatted: number | null +} + +export type TActivityZapAssets = { + inputAsset: TActivityInputAsset + outputAsset: TActivityOutputAsset + outputKind: 'stake' | 'token' +} + +export type TDirectV2VaultAction = { + action: 'deposit' | 'withdraw' + assetAmount: bigint +} + +export type TZapperV2Zap = { + inputAsset: TActivityInputAsset + assetAmount: bigint +} + +export type TZapperV2ZapOut = { + outputAsset: TActivityOutputAsset + assetAmount: bigint +} + +type TActivityTransferDirection = 'input' | 'output' + +const receiptAssetCache = new Map>() +const ycrvZapInputCache = new Map>() +const rewardClaimTransactionCache = new Map>() +const directV2VaultActionCache = new Map>() +const zapperV2ZapCache = new Map>() +const zapperV2ZapOutCache = new Map>() +const tokenMetadataCache = new Map>() + +function toDecodeTopics(topics: string[]): TDecodeTopics { + return topics.length === 0 ? [] : [topics[0] as Hex, ...(topics.slice(1) as Hex[])] +} + +function getChainRpcUrl(chainId: number): string | null { + const rpcUrl = process.env[`VITE_RPC_URI_FOR_${chainId}`]?.trim() + return rpcUrl && rpcUrl.length > 0 ? rpcUrl : null +} + +async function fetchRpc(chainId: number, method: string, params: unknown[], attempt = 0): Promise { + const rpcUrl = getChainRpcUrl(chainId) + + if (!rpcUrl) { + return null + } + + try { + const response = await fetch(rpcUrl, { + method: 'POST', + headers: { + 'content-type': 'application/json' + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method, + params + }), + signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS) + }) + + if (!response.ok) { + throw new Error(`RPC request failed: ${response.status}`) + } + + const payload = (await response.json()) as { + result: T | null + error?: { + message?: string + } + } + + if (payload.error) { + throw new Error(payload.error.message ?? 'RPC request returned an error') + } + + return payload.result + } catch (error) { + if (attempt >= DEFAULT_MAX_RETRIES) { + debugError('activity', 'failed activity receipt enrichment RPC request', error, { chainId, method }) + return null + } + + return fetchRpc(chainId, method, params, attempt + 1) + } +} + +function decodeTransferLog(log: TRpcReceiptLog): TDecodedTransfer | null { + try { + const decoded = decodeEventLog({ + abi: [TRANSFER_EVENT], + data: log.data as Hex, + topics: toDecodeTopics(log.topics) + }) + const args = decoded.args as { + from: string + to: string + value: bigint + } + + return { + tokenAddress: lowerCaseAddress(log.address), + from: lowerCaseAddress(args.from), + to: lowerCaseAddress(args.to), + value: args.value + } + } catch { + return null + } +} + +function decodeZapperZapInLog( + log: TRpcReceiptLog +): { emitter: string; sender: string; pool: string; tokensRec: bigint } | null { + try { + const decoded = decodeEventLog({ + abi: [ZAPPER_ZAP_IN_EVENT], + data: log.data as Hex, + topics: toDecodeTopics(log.topics) + }) + const args = decoded.args as { + sender: string + pool: string + tokensRec: bigint + } + + return { + emitter: lowerCaseAddress(log.address), + sender: lowerCaseAddress(args.sender), + pool: lowerCaseAddress(args.pool), + tokensRec: args.tokensRec + } + } catch { + return null + } +} + +function decodeZapperZapOutLog( + log: TRpcReceiptLog +): { emitter: string; sender: string; pool: string; token: string; tokensRec: bigint } | null { + try { + const decoded = decodeEventLog({ + abi: [ZAPPER_ZAP_OUT_EVENT], + data: log.data as Hex, + topics: toDecodeTopics(log.topics) + }) + const args = decoded.args as { + sender: string + pool: string + token: string + tokensRec: bigint + } + + return { + emitter: lowerCaseAddress(log.address), + sender: lowerCaseAddress(args.sender), + pool: lowerCaseAddress(args.pool), + token: lowerCaseAddress(args.token), + tokensRec: args.tokensRec + } + } catch { + return null + } +} + +function decodeBytes32Symbol(data: string): string | null { + try { + const decoded = hexToString(data as Hex) + .replace(/\0+$/g, '') + .trim() + return decoded.length > 0 ? decoded : null + } catch { + return null + } +} + +async function fetchTokenMetadata(chainId: number, tokenAddress: string): Promise { + const cacheKey = `${chainId}:${lowerCaseAddress(tokenAddress)}` + const existing = tokenMetadataCache.get(cacheKey) + + if (existing) { + return existing + } + + const request = (async () => { + const [symbolResult, decimalsResult] = await Promise.all([ + fetchRpc(chainId, 'eth_call', [ + { + to: tokenAddress, + data: encodeFunctionData({ + abi: erc20Abi, + functionName: 'symbol' + }) + }, + 'latest' + ]), + fetchRpc(chainId, 'eth_call', [ + { + to: tokenAddress, + data: encodeFunctionData({ + abi: erc20Abi, + functionName: 'decimals' + }) + }, + 'latest' + ]) + ]) + + const symbol = + symbolResult === null + ? null + : (() => { + try { + return decodeFunctionResult({ + abi: erc20Abi, + functionName: 'symbol', + data: symbolResult as Hex + }) as string + } catch { + return decodeBytes32Symbol(symbolResult) + } + })() + const decimals = + decimalsResult === null + ? null + : (() => { + try { + return Number( + decodeFunctionResult({ + abi: erc20Abi, + functionName: 'decimals', + data: decimalsResult as Hex + }) + ) + } catch { + return null + } + })() + + return { + symbol, + decimals: Number.isFinite(decimals) ? decimals : null + } + })() + + tokenMetadataCache.set(cacheKey, request) + return request +} + +function selectSingleUserTransfer(args: { + receipt: TRpcTransactionReceipt + userAddress: string + excludedTokenAddresses: Set + direction: TActivityTransferDirection +}): { tokenAddress: string; value: bigint } | null { + const normalizedUserAddress = lowerCaseAddress(args.userAddress) + const groupedTransfers = (args.receipt.logs ?? []) + .map(decodeTransferLog) + .filter( + (transfer): transfer is TDecodedTransfer => + transfer !== null && + transfer.value > ZERO && + (args.direction === 'input' + ? transfer.from === normalizedUserAddress + : transfer.to === normalizedUserAddress) && + !args.excludedTokenAddresses.has(transfer.tokenAddress) + ) + .reduce>((grouped, transfer) => { + grouped.set(transfer.tokenAddress, (grouped.get(transfer.tokenAddress) ?? ZERO) + transfer.value) + return grouped + }, new Map()) + + if (groupedTransfers.size !== 1) { + return null + } + + const [entry] = groupedTransfers.entries() + if (!entry) { + return null + } + + const [tokenAddress, value] = entry + return { tokenAddress, value } +} + +function selectSingleTokenTransfer(args: { + receipt: TRpcTransactionReceipt + from: string + to: string + excludedTokenAddresses: Set +}): { tokenAddress: string; value: bigint } | null { + const normalizedFrom = lowerCaseAddress(args.from) + const normalizedTo = lowerCaseAddress(args.to) + const groupedTransfers = (args.receipt.logs ?? []) + .map(decodeTransferLog) + .filter( + (transfer): transfer is TDecodedTransfer => + transfer !== null && + transfer.value > ZERO && + transfer.from === normalizedFrom && + transfer.to === normalizedTo && + !args.excludedTokenAddresses.has(transfer.tokenAddress) + ) + .reduce>((grouped, transfer) => { + grouped.set(transfer.tokenAddress, (grouped.get(transfer.tokenAddress) ?? ZERO) + transfer.value) + return grouped + }, new Map()) + + if (groupedTransfers.size !== 1) { + return null + } + + const [entry] = groupedTransfers.entries() + if (!entry) { + return null + } + + const [tokenAddress, value] = entry + return { tokenAddress, value } +} + +function decodeDirectV2VaultAction(args: { + transaction: TRpcTransaction + vaultAddress: string + transferDirection: 'in' | 'out' +}): { action: 'deposit'; assetAmount: bigint } | { action: 'withdraw' } | null { + const transactionTo = args.transaction.to ? lowerCaseAddress(args.transaction.to) : null + const input = args.transaction.input + + if (!transactionTo || transactionTo !== lowerCaseAddress(args.vaultAddress) || !input) { + return null + } + + try { + const decoded = decodeFunctionData({ + abi: V2_VAULT_ACTIVITY_ABI, + data: input as Hex + }) + + if (decoded.functionName === 'deposit' && args.transferDirection === 'in') { + const [assetAmount] = decoded.args + return assetAmount > ZERO ? { action: 'deposit', assetAmount } : null + } + + return decoded.functionName === 'withdraw' && args.transferDirection === 'out' ? { action: 'withdraw' } : null + } catch { + return null + } +} + +function getKnownYcrvZapOutputSymbol(chainId: number, tokenAddress: string): string | null { + if (chainId !== 1) { + return null + } + + const normalizedTokenAddress = lowerCaseAddress(tokenAddress) + + if (normalizedTokenAddress === lowerCaseAddress('0xe9a115b77a1057c918f997c32663fdce24fb873f')) { + return 'yCRV Boosted Staker' + } + + return null +} + +function getKnownYcrvZapOutputKind(chainId: number, tokenAddress: string): TActivityZapAssets['outputKind'] { + if ( + chainId === 1 && + lowerCaseAddress(tokenAddress) === lowerCaseAddress('0xe9a115b77a1057c918f997c32663fdce24fb873f') + ) { + return 'stake' + } + + return 'token' +} + +function decodeKnownYcrvZapInput(args: { chainId: number; transaction: TRpcTransaction }): { + inputTokenAddress: string + outputTokenAddress: string + inputAmount: bigint | null +} | null { + const knownContracts = KNOWN_YCRV_ZAP_CONTRACTS_BY_CHAIN.get(args.chainId) + const transactionTo = args.transaction.to ? lowerCaseAddress(args.transaction.to) : null + const input = args.transaction.input + + if (!knownContracts || !transactionTo || !input || !knownContracts.has(transactionTo)) { + return null + } + + try { + const decoded = decodeFunctionData({ + abi: YCRV_ZAP_ABI, + data: input as Hex + }) + + if (decoded.functionName !== 'zap') { + return null + } + + const [inputToken, outputToken, amountIn] = decoded.args + + return { + inputTokenAddress: lowerCaseAddress(inputToken), + outputTokenAddress: lowerCaseAddress(outputToken), + inputAmount: typeof amountIn === 'bigint' && amountIn > ZERO ? amountIn : null + } + } catch { + return null + } +} + +function isKnownRewardDistributorTransaction(args: { chainId: number; transaction: TRpcTransaction }): boolean { + const knownContracts = KNOWN_REWARD_DISTRIBUTOR_CONTRACTS_BY_CHAIN.get(args.chainId) + const transactionTo = args.transaction.to ? lowerCaseAddress(args.transaction.to) : null + + return Boolean(knownContracts && transactionTo && knownContracts.has(transactionTo)) +} + +function getKnownZapperV2Contract(args: { chainId: number; transaction: TRpcTransaction }): string | null { + const knownContracts = KNOWN_ZAPPER_V2_CONTRACTS_BY_CHAIN.get(args.chainId) + const transactionTo = args.transaction.to ? lowerCaseAddress(args.transaction.to) : null + + return knownContracts && transactionTo && knownContracts.has(transactionTo) ? transactionTo : null +} + +function hasMatchingZapperZapIn(args: { + receipt: TRpcTransactionReceipt + zapperContract: string + userAddress: string + vaultAddress: string + shareAmount: bigint +}): boolean { + const normalizedUserAddress = lowerCaseAddress(args.userAddress) + const normalizedVaultAddress = lowerCaseAddress(args.vaultAddress) + const normalizedZapperContract = lowerCaseAddress(args.zapperContract) + + return (args.receipt.logs ?? []) + .map(decodeZapperZapInLog) + .some( + (event) => + event !== null && + event.emitter === normalizedZapperContract && + event.sender === normalizedUserAddress && + event.pool === normalizedVaultAddress && + event.tokensRec === args.shareAmount + ) +} + +function getMatchingZapperZapOut(args: { + receipt: TRpcTransactionReceipt + zapperContract: string + userAddress: string + vaultAddress: string +}): { token: string; tokensRec: bigint } | null { + const normalizedUserAddress = lowerCaseAddress(args.userAddress) + const normalizedVaultAddress = lowerCaseAddress(args.vaultAddress) + const normalizedZapperContract = lowerCaseAddress(args.zapperContract) + + return ( + (args.receipt.logs ?? []) + .map(decodeZapperZapOutLog) + .find( + (event) => + event !== null && + event.emitter === normalizedZapperContract && + event.sender === normalizedUserAddress && + event.pool === normalizedVaultAddress && + event.tokensRec > ZERO + ) ?? null + ) +} + +function hasMatchingTokenTransfer(args: { + receipt: TRpcTransactionReceipt + tokenAddress: string + from: string + to: string + value: bigint +}): boolean { + const normalizedTokenAddress = lowerCaseAddress(args.tokenAddress) + const normalizedFrom = lowerCaseAddress(args.from) + const normalizedTo = lowerCaseAddress(args.to) + + return (args.receipt.logs ?? []) + .map(decodeTransferLog) + .some( + (transfer) => + transfer !== null && + transfer.tokenAddress === normalizedTokenAddress && + transfer.from === normalizedFrom && + transfer.to === normalizedTo && + transfer.value === args.value + ) +} + +async function fetchRouterAssetForActivity(args: { + chainId: number + transactionHash: string + userAddress: string + excludedTokenAddresses?: string[] + direction: TActivityTransferDirection +}): Promise { + const cacheKey = [ + args.direction, + args.chainId, + lowerCaseAddress(args.transactionHash), + lowerCaseAddress(args.userAddress), + [...(args.excludedTokenAddresses ?? [])].map(lowerCaseAddress).sort().join(',') + ].join(':') + const existing = receiptAssetCache.get(cacheKey) + + if (existing) { + return existing + } + + const request = (async () => { + const receipt = await fetchRpc(args.chainId, 'eth_getTransactionReceipt', [ + args.transactionHash + ]) + + if (!receipt) { + return null + } + + const transfer = selectSingleUserTransfer({ + receipt, + userAddress: args.userAddress, + excludedTokenAddresses: new Set((args.excludedTokenAddresses ?? []).map(lowerCaseAddress)), + direction: args.direction + }) + + if (!transfer) { + return null + } + + const metadata = await fetchTokenMetadata(args.chainId, transfer.tokenAddress) + + return { + tokenAddress: transfer.tokenAddress, + tokenSymbol: metadata.symbol, + amount: transfer.value.toString(), + amountFormatted: metadata.decimals === null ? null : formatAmount(transfer.value, metadata.decimals) + } + })() + + receiptAssetCache.set(cacheKey, request) + return request +} + +export async function fetchRouterInputAssetForActivity(args: { + chainId: number + transactionHash: string + userAddress: string + excludedTokenAddresses?: string[] +}): Promise { + return fetchRouterAssetForActivity({ ...args, direction: 'input' }) +} + +export async function fetchRouterOutputAssetForActivity(args: { + chainId: number + transactionHash: string + userAddress: string + excludedTokenAddresses?: string[] +}): Promise { + return fetchRouterAssetForActivity({ ...args, direction: 'output' }) +} + +export async function fetchYcrvZapInputAssetForActivity(args: { + chainId: number + transactionHash: string + userAddress: string + excludedTokenAddresses?: string[] +}): Promise { + const cacheKey = [ + args.chainId, + lowerCaseAddress(args.transactionHash), + lowerCaseAddress(args.userAddress), + [...(args.excludedTokenAddresses ?? [])].map(lowerCaseAddress).sort().join(',') + ].join(':') + const existing = ycrvZapInputCache.get(cacheKey) + + if (existing) { + return existing + } + + const request = (async () => { + const transaction = await fetchRpc(args.chainId, 'eth_getTransactionByHash', [ + args.transactionHash + ]) + const decodedZap = transaction ? decodeKnownYcrvZapInput({ chainId: args.chainId, transaction }) : null + + if (!decodedZap) { + return null + } + + const receiptInput = + decodedZap.inputAmount === null + ? await (async () => { + const receipt = await fetchRpc(args.chainId, 'eth_getTransactionReceipt', [ + args.transactionHash + ]) + + return receipt + ? selectSingleUserTransfer({ + receipt, + userAddress: args.userAddress, + excludedTokenAddresses: new Set((args.excludedTokenAddresses ?? []).map(lowerCaseAddress)), + direction: 'input' + }) + : null + })() + : null + const tokenAddress = receiptInput?.tokenAddress ?? decodedZap.inputTokenAddress + const amount = receiptInput?.value ?? decodedZap.inputAmount + + if (amount === null || amount <= ZERO) { + return null + } + + const [inputMetadata, outputMetadata] = await Promise.all([ + fetchTokenMetadata(args.chainId, tokenAddress), + fetchTokenMetadata(args.chainId, decodedZap.outputTokenAddress) + ]) + + return { + inputAsset: { + tokenAddress, + tokenSymbol: inputMetadata.symbol, + amount: amount.toString(), + amountFormatted: inputMetadata.decimals === null ? null : formatAmount(amount, inputMetadata.decimals) + }, + outputAsset: { + tokenAddress: decodedZap.outputTokenAddress, + tokenSymbol: getKnownYcrvZapOutputSymbol(args.chainId, decodedZap.outputTokenAddress) ?? outputMetadata.symbol, + amount: null, + amountFormatted: null + }, + outputKind: getKnownYcrvZapOutputKind(args.chainId, decodedZap.outputTokenAddress) + } + })() + + ycrvZapInputCache.set(cacheKey, request) + return request +} + +export async function fetchIsRewardClaimForActivity(args: { + chainId: number + transactionHash: string +}): Promise { + const cacheKey = `${args.chainId}:${lowerCaseAddress(args.transactionHash)}` + const existing = rewardClaimTransactionCache.get(cacheKey) + + if (existing) { + return existing + } + + const request = (async () => { + const transaction = await fetchRpc(args.chainId, 'eth_getTransactionByHash', [ + args.transactionHash + ]) + + return transaction ? isKnownRewardDistributorTransaction({ chainId: args.chainId, transaction }) : false + })() + + rewardClaimTransactionCache.set(cacheKey, request) + return request +} + +export async function fetchDirectV2VaultActionForActivity(args: { + chainId: number + transactionHash: string + userAddress: string + vaultAddress: string + transferDirection: 'in' | 'out' +}): Promise { + const cacheKey = [ + args.chainId, + lowerCaseAddress(args.transactionHash), + lowerCaseAddress(args.userAddress), + lowerCaseAddress(args.vaultAddress), + args.transferDirection + ].join(':') + const existing = directV2VaultActionCache.get(cacheKey) + + if (existing) { + return existing + } + + const request = (async () => { + const transaction = await fetchRpc(args.chainId, 'eth_getTransactionByHash', [ + args.transactionHash + ]) + const directAction = transaction + ? decodeDirectV2VaultAction({ + transaction, + vaultAddress: args.vaultAddress, + transferDirection: args.transferDirection + }) + : null + + if (!directAction) { + return null + } + + if (directAction.action === 'deposit') { + return directAction + } + + const receipt = await fetchRpc(args.chainId, 'eth_getTransactionReceipt', [ + args.transactionHash + ]) + const outputTransfer = receipt + ? selectSingleTokenTransfer({ + receipt, + from: args.vaultAddress, + to: args.userAddress, + excludedTokenAddresses: new Set([lowerCaseAddress(args.vaultAddress)]) + }) + : null + + return outputTransfer ? ({ action: 'withdraw', assetAmount: outputTransfer.value } as const) : null + })() + + directV2VaultActionCache.set(cacheKey, request) + return request +} + +export async function fetchZapperV2ZapForActivity(args: { + chainId: number + transactionHash: string + userAddress: string + vaultAddress: string + shareAmount: bigint + excludedTokenAddresses?: string[] +}): Promise { + const cacheKey = [ + args.chainId, + lowerCaseAddress(args.transactionHash), + lowerCaseAddress(args.userAddress), + lowerCaseAddress(args.vaultAddress), + args.shareAmount.toString(), + [...(args.excludedTokenAddresses ?? [])].map(lowerCaseAddress).sort().join(',') + ].join(':') + const existing = zapperV2ZapCache.get(cacheKey) + + if (existing) { + return existing + } + + const request = (async () => { + const transaction = await fetchRpc(args.chainId, 'eth_getTransactionByHash', [ + args.transactionHash + ]) + const zapperContract = transaction ? getKnownZapperV2Contract({ chainId: args.chainId, transaction }) : null + + if (!zapperContract) { + return null + } + + const receipt = await fetchRpc(args.chainId, 'eth_getTransactionReceipt', [ + args.transactionHash + ]) + + if ( + !receipt || + !hasMatchingZapperZapIn({ + receipt, + zapperContract, + userAddress: args.userAddress, + vaultAddress: args.vaultAddress, + shareAmount: args.shareAmount + }) + ) { + return null + } + + const inputTransfer = selectSingleTokenTransfer({ + receipt, + from: args.userAddress, + to: zapperContract, + excludedTokenAddresses: new Set([ + lowerCaseAddress(args.vaultAddress), + ...(args.excludedTokenAddresses ?? []).map(lowerCaseAddress) + ]) + }) + + if (!inputTransfer) { + return null + } + + const metadata = await fetchTokenMetadata(args.chainId, inputTransfer.tokenAddress) + const inputAsset = { + tokenAddress: inputTransfer.tokenAddress, + tokenSymbol: metadata.symbol, + amount: inputTransfer.value.toString(), + amountFormatted: metadata.decimals === null ? null : formatAmount(inputTransfer.value, metadata.decimals) + } + + return { + inputAsset, + assetAmount: inputTransfer.value + } + })() + + zapperV2ZapCache.set(cacheKey, request) + return request +} + +export async function fetchZapperV2ZapOutForActivity(args: { + chainId: number + transactionHash: string + userAddress: string + vaultAddress: string + shareAmount: bigint +}): Promise { + const cacheKey = [ + args.chainId, + lowerCaseAddress(args.transactionHash), + lowerCaseAddress(args.userAddress), + lowerCaseAddress(args.vaultAddress), + args.shareAmount.toString() + ].join(':') + const existing = zapperV2ZapOutCache.get(cacheKey) + + if (existing) { + return existing + } + + const request = (async () => { + const transaction = await fetchRpc(args.chainId, 'eth_getTransactionByHash', [ + args.transactionHash + ]) + const zapperContract = transaction ? getKnownZapperV2Contract({ chainId: args.chainId, transaction }) : null + + if (!zapperContract) { + return null + } + + const receipt = await fetchRpc(args.chainId, 'eth_getTransactionReceipt', [ + args.transactionHash + ]) + const zapOut = receipt + ? getMatchingZapperZapOut({ + receipt, + zapperContract, + userAddress: args.userAddress, + vaultAddress: args.vaultAddress + }) + : null + + if ( + !receipt || + !zapOut || + !hasMatchingTokenTransfer({ + receipt, + tokenAddress: args.vaultAddress, + from: args.userAddress, + to: zapperContract, + value: args.shareAmount + }) || + !hasMatchingTokenTransfer({ + receipt, + tokenAddress: zapOut.token, + from: zapperContract, + to: args.userAddress, + value: zapOut.tokensRec + }) + ) { + return null + } + + const metadata = await fetchTokenMetadata(args.chainId, zapOut.token) + + return { + outputAsset: { + tokenAddress: zapOut.token, + tokenSymbol: metadata.symbol, + amount: zapOut.tokensRec.toString(), + amountFormatted: metadata.decimals === null ? null : formatAmount(zapOut.tokensRec, metadata.decimals) + }, + assetAmount: zapOut.tokensRec + } + })() + + zapperV2ZapOutCache.set(cacheKey, request) + return request +} diff --git a/api/lib/holdings/services/aggregator.test.ts b/api/lib/holdings/services/aggregator.test.ts new file mode 100644 index 000000000..cb3a6d939 --- /dev/null +++ b/api/lib/holdings/services/aggregator.test.ts @@ -0,0 +1,780 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const getCachedTotalsWithTimestampMock = vi.fn() +const saveCachedTotalsMock = vi.fn() +const clearUserCacheMock = vi.fn() +const checkCacheStalenessMock = vi.fn() +const fetchUserEventsMock = vi.fn() +const buildPositionTimelineMock = vi.fn() +const generateDailyTimestampsMock = vi.fn() +const generateDailyTimestampsFromRangeMock = vi.fn() +const getShareBalanceAtTimestampMock = vi.fn() +const getUniqueVaultsMock = vi.fn() +const toSettledDayTimestampMock = vi.fn() +const timestampToDateStringMock = vi.fn() +const fetchMultipleVaultsMetadataMock = vi.fn() +const fetchMultipleVaultsPPSMock = vi.fn() +const getPPSMock = vi.fn() +const fetchHistoricalPricesMock = vi.fn() +const getHistoricalPriceFetchFailedBatchesMock = vi.fn() +const getChainPrefixMock = vi.fn() +const getPriceAtTimestampMock = vi.fn() +const CURRENT_DAY_LOOKAHEAD_SECONDS = 24 * 60 * 60 + +vi.mock('./cache', () => ({ + getCachedTotalsWithTimestamp: getCachedTotalsWithTimestampMock, + saveCachedTotals: saveCachedTotalsMock, + clearUserCache: clearUserCacheMock, + checkCacheStaleness: checkCacheStalenessMock +})) + +vi.mock('./graphql', () => ({ + fetchUserEvents: fetchUserEventsMock +})) + +vi.mock('./holdings', () => ({ + buildPositionTimeline: buildPositionTimelineMock, + generateDailyTimestamps: generateDailyTimestampsMock, + generateDailyTimestampsFromRange: generateDailyTimestampsFromRangeMock, + getShareBalanceAtTimestamp: getShareBalanceAtTimestampMock, + getUniqueVaults: getUniqueVaultsMock, + toSettledDayTimestamp: toSettledDayTimestampMock, + timestampToDateString: timestampToDateStringMock +})) + +vi.mock('./vaults', () => ({ + fetchMultipleVaultsMetadata: fetchMultipleVaultsMetadataMock +})) + +vi.mock('./kong', () => ({ + fetchMultipleVaultsPPS: fetchMultipleVaultsPPSMock, + getPPS: getPPSMock +})) + +vi.mock('./defillama', () => ({ + fetchHistoricalPrices: fetchHistoricalPricesMock, + fetchHistoricalPricesForTokenTimestamps: fetchHistoricalPricesMock, + getHistoricalPriceFetchFailedBatches: getHistoricalPriceFetchFailedBatchesMock, + getChainPrefix: getChainPrefixMock, + getPriceAtTimestamp: getPriceAtTimestampMock +})) + +describe('getHistoricalHoldings', () => { + beforeEach(() => { + toSettledDayTimestampMock.mockImplementation((timestamp: number) => timestamp + 1) + checkCacheStalenessMock.mockResolvedValue(false) + clearUserCacheMock.mockResolvedValue(0) + getHistoricalPriceFetchFailedBatchesMock.mockReturnValue(0) + }) + + afterEach(() => { + vi.clearAllMocks() + vi.restoreAllMocks() + }) + + it('caches versioned history separately and filters vaults using authoritative metadata version', async () => { + vi.spyOn(Date, 'now').mockReturnValue(999_000) + + const userAddress = '0x93a62da5a14c80f265dabc077fcee437b1a0efde' + const v2VaultAddress = '0x00000000000000000000000000000000000000a2' + const v3VaultAddress = '0x00000000000000000000000000000000000000a3' + const v2TokenAddress = '0x0000000000000000000000000000000000000aa2' + const v3TokenAddress = '0x0000000000000000000000000000000000000aa3' + const timeline = [{ id: 'v2-entry' }, { id: 'v3-entry' }] + const vaults = [ + { chainId: 1, vaultAddress: v2VaultAddress }, + { chainId: 1, vaultAddress: v3VaultAddress } + ] + + generateDailyTimestampsMock.mockReturnValue([100]) + timestampToDateStringMock.mockImplementation((timestamp: number) => `date-${timestamp}`) + getCachedTotalsWithTimestampMock.mockResolvedValue({ totals: [], oldestUpdatedAt: null }) + fetchUserEventsMock.mockResolvedValue({ + deposits: [], + withdrawals: [], + transfersIn: [], + transfersOut: [] + }) + buildPositionTimelineMock.mockReturnValue(timeline) + getUniqueVaultsMock.mockReturnValue(vaults) + fetchMultipleVaultsMetadataMock.mockResolvedValue( + new Map([ + [ + `1:${v2VaultAddress}`, + { + address: v2VaultAddress, + chainId: 1, + version: 'v2', + token: { + address: v2TokenAddress, + symbol: 'TKN2', + decimals: 18 + }, + decimals: 18 + } + ], + [ + `1:${v3VaultAddress}`, + { + address: v3VaultAddress, + chainId: 1, + version: 'v3', + token: { + address: v3TokenAddress, + symbol: 'TKN3', + decimals: 18 + }, + decimals: 18 + } + ] + ]) + ) + fetchMultipleVaultsPPSMock.mockImplementation(async (requestedVaults: typeof vaults) => { + return new Map( + requestedVaults.map((vault) => [`${vault.chainId}:${vault.vaultAddress.toLowerCase()}`, new Map([[100, 1]])]) + ) + }) + fetchHistoricalPricesMock.mockResolvedValue(new Map([[`ethereum:${v2TokenAddress}`, new Map([[101, 1]])]])) + getChainPrefixMock.mockReturnValue('ethereum') + getPPSMock.mockReturnValue(1) + getPriceAtTimestampMock.mockReturnValue(1) + getShareBalanceAtTimestampMock.mockImplementation((_timeline: unknown, vaultAddress: string) => { + return vaultAddress === v2VaultAddress ? 2n * 10n ** 18n : 5n * 10n ** 18n + }) + generateDailyTimestampsFromRangeMock.mockReturnValue([]) + checkCacheStalenessMock.mockResolvedValue(false) + + const { getHistoricalHoldings } = await import('./aggregator') + const response = await getHistoricalHoldings(userAddress, 'v2', 'parallel', 'all') + + expect(fetchUserEventsMock).toHaveBeenCalledWith( + userAddress, + 'all', + 101 + CURRENT_DAY_LOOKAHEAD_SECONDS, + 'parallel', + 'all' + ) + expect(getCachedTotalsWithTimestampMock).toHaveBeenCalledWith(userAddress, 'v2', 'date-100', 'date-100') + expect(fetchMultipleVaultsPPSMock).toHaveBeenCalledWith([vaults[0]]) + expect(saveCachedTotalsMock).toHaveBeenCalledWith(userAddress, 'v2', [{ date: 'date-100', usdValue: 2 }]) + expect(response.hasActivity).toBe(true) + expect(response.dataPoints).toEqual([{ date: 'date-100', timestamp: 101, totalUsdValue: 2 }]) + }) + + it('defaults history event fetching to sequential paged mode', async () => { + const userAddress = '0x93a62da5a14c80f265dabc077fcee437b1a0efde' + + generateDailyTimestampsMock.mockReturnValue([100]) + timestampToDateStringMock.mockImplementation((timestamp: number) => `date-${timestamp}`) + getCachedTotalsWithTimestampMock.mockResolvedValue({ totals: [], oldestUpdatedAt: null }) + fetchUserEventsMock.mockResolvedValue({ + deposits: [], + withdrawals: [], + transfersIn: [], + transfersOut: [] + }) + buildPositionTimelineMock.mockReturnValue([]) + generateDailyTimestampsFromRangeMock.mockReturnValue([]) + + const { getHistoricalHoldings } = await import('./aggregator') + await getHistoricalHoldings(userAddress, 'all') + + expect(fetchUserEventsMock).toHaveBeenCalledWith( + userAddress, + 'all', + 101 + CURRENT_DAY_LOOKAHEAD_SECONDS, + 'seq', + 'paged' + ) + }) + + it('filters historical chart calculations to requested vaults without using aggregate cache', async () => { + const userAddress = '0x93a62da5a14c80f265dabc077fcee437b1a0efde' + const firstVaultAddress = '0x00000000000000000000000000000000000000a1' + const secondVaultAddress = '0x00000000000000000000000000000000000000a2' + const excludedVaultAddress = '0x00000000000000000000000000000000000000a3' + const tokenAddress = '0x0000000000000000000000000000000000000aa1' + const timeline = [{ blockTimestamp: 100, blockNumber: 1 }] + const vaults = [ + { chainId: 1, vaultAddress: firstVaultAddress }, + { chainId: 1, vaultAddress: secondVaultAddress }, + { chainId: 1, vaultAddress: excludedVaultAddress } + ] + + generateDailyTimestampsMock.mockReturnValue([100]) + timestampToDateStringMock.mockImplementation((timestamp: number) => `date-${timestamp}`) + fetchUserEventsMock.mockResolvedValue({ + deposits: [], + withdrawals: [], + transfersIn: [], + transfersOut: [] + }) + buildPositionTimelineMock.mockReturnValue(timeline) + getUniqueVaultsMock.mockReturnValue(vaults) + fetchMultipleVaultsMetadataMock.mockResolvedValue( + new Map( + vaults.map((vault) => [ + `1:${vault.vaultAddress}`, + { + address: vault.vaultAddress, + chainId: 1, + version: 'v3', + token: { + address: tokenAddress, + symbol: 'TKN', + decimals: 18 + }, + decimals: 18 + } + ]) + ) + ) + fetchMultipleVaultsPPSMock.mockImplementation(async (requestedVaults: typeof vaults) => { + return new Map(requestedVaults.map((vault) => [`1:${vault.vaultAddress}`, new Map([[101, 1]])])) + }) + fetchHistoricalPricesMock.mockResolvedValue(new Map([[`ethereum:${tokenAddress}`, new Map([[101, 1]])]])) + getChainPrefixMock.mockReturnValue('ethereum') + getPPSMock.mockReturnValue(1) + getPriceAtTimestampMock.mockReturnValue(1) + getShareBalanceAtTimestampMock.mockImplementation((_timeline: unknown, vaultAddress: string) => { + if (vaultAddress === firstVaultAddress) return 2n * 10n ** 18n + if (vaultAddress === secondVaultAddress) return 3n * 10n ** 18n + return 100n * 10n ** 18n + }) + + const { getHistoricalHoldingsChart } = await import('./aggregator') + const response = await getHistoricalHoldingsChart(userAddress, 'all', 'parallel', 'all', 'usd', '1y', [ + { chainId: 1, vaultAddress: firstVaultAddress }, + { chainId: 1, vaultAddress: secondVaultAddress } + ]) + + expect(getCachedTotalsWithTimestampMock).not.toHaveBeenCalled() + expect(saveCachedTotalsMock).not.toHaveBeenCalled() + expect(fetchMultipleVaultsPPSMock).toHaveBeenCalledWith([ + { chainId: 1, vaultAddress: firstVaultAddress }, + { chainId: 1, vaultAddress: secondVaultAddress } + ]) + expect(response.dataPoints).toEqual([{ date: 'date-100', timestamp: 101, value: 5 }]) + }) + + it('returns fully cached history after validating cache staleness', async () => { + const userAddress = '0x93a62da5a14c80f265dabc077fcee437b1a0efde' + const vaultAddress = '0x00000000000000000000000000000000000000dd' + const tokenAddress = '0x0000000000000000000000000000000000000dd0' + + generateDailyTimestampsMock.mockReturnValue([100, 200]) + timestampToDateStringMock.mockImplementation((timestamp: number) => `date-${timestamp}`) + getCachedTotalsWithTimestampMock.mockResolvedValue({ + totals: [ + { date: 'date-100', usdValue: 1 }, + { date: 'date-200', usdValue: 2 } + ], + oldestUpdatedAt: new Date('2026-03-31T00:00:00Z') + }) + fetchUserEventsMock.mockResolvedValue({ + deposits: [], + withdrawals: [], + transfersIn: [], + transfersOut: [] + }) + buildPositionTimelineMock.mockReturnValue([{ id: 'cached-entry' }]) + getUniqueVaultsMock.mockReturnValue([{ chainId: 1, vaultAddress }]) + fetchMultipleVaultsMetadataMock.mockResolvedValue( + new Map([ + [ + `1:${vaultAddress}`, + { + address: vaultAddress, + chainId: 1, + version: 'v3', + token: { + address: tokenAddress, + symbol: 'CACHE', + decimals: 18 + }, + decimals: 18 + } + ] + ]) + ) + generateDailyTimestampsFromRangeMock.mockReturnValue([]) + + const { getHistoricalHoldings } = await import('./aggregator') + const response = await getHistoricalHoldings(userAddress, 'all') + + expect(fetchUserEventsMock).toHaveBeenCalledWith( + userAddress, + 'all', + 201 + CURRENT_DAY_LOOKAHEAD_SECONDS, + 'seq', + 'paged' + ) + expect(fetchMultipleVaultsMetadataMock).toHaveBeenCalled() + expect(checkCacheStalenessMock).toHaveBeenCalledWith( + [{ address: vaultAddress, chainId: 1 }], + new Date('2026-03-31T00:00:00Z') + ) + expect(fetchMultipleVaultsPPSMock).not.toHaveBeenCalled() + expect(fetchHistoricalPricesMock).not.toHaveBeenCalled() + expect(response.dataPoints).toEqual([ + { date: 'date-100', timestamp: 101, totalUsdValue: 1 }, + { date: 'date-200', timestamp: 201, totalUsdValue: 2 } + ]) + }) + + it('recomputes stale fully cached history after vault invalidation', async () => { + const userAddress = '0x93a62da5a14c80f265dabc077fcee437b1a0efde' + const vaultAddress = '0x00000000000000000000000000000000000000ee' + const tokenAddress = '0x0000000000000000000000000000000000000ee0' + + generateDailyTimestampsMock.mockReturnValue([100, 200]) + timestampToDateStringMock.mockImplementation((timestamp: number) => `date-${timestamp}`) + getCachedTotalsWithTimestampMock.mockResolvedValue({ + totals: [ + { date: 'date-100', usdValue: 1 }, + { date: 'date-200', usdValue: 2 } + ], + oldestUpdatedAt: new Date('2026-03-31T00:00:00Z') + }) + fetchUserEventsMock.mockResolvedValue({ + deposits: [], + withdrawals: [], + transfersIn: [], + transfersOut: [] + }) + buildPositionTimelineMock.mockReturnValue([{ id: 'stale-entry' }]) + getUniqueVaultsMock.mockReturnValue([{ chainId: 1, vaultAddress }]) + fetchMultipleVaultsMetadataMock.mockResolvedValue( + new Map([ + [ + `1:${vaultAddress}`, + { + address: vaultAddress, + chainId: 1, + version: 'v3', + token: { + address: tokenAddress, + symbol: 'STALE', + decimals: 18 + }, + decimals: 18 + } + ] + ]) + ) + fetchMultipleVaultsPPSMock.mockResolvedValue( + new Map([ + [ + `1:${vaultAddress}`, + new Map([ + [101, 2], + [201, 2] + ]) + ] + ]) + ) + fetchHistoricalPricesMock.mockResolvedValue( + new Map([ + [ + `ethereum:${tokenAddress}`, + new Map([ + [101, 3], + [201, 3] + ]) + ] + ]) + ) + getChainPrefixMock.mockReturnValue('ethereum') + getPPSMock.mockReturnValue(2) + getPriceAtTimestampMock.mockReturnValue(3) + getShareBalanceAtTimestampMock.mockReturnValue(1n * 10n ** 18n) + checkCacheStalenessMock.mockResolvedValue(true) + + const { getHistoricalHoldings } = await import('./aggregator') + const response = await getHistoricalHoldings(userAddress, 'all') + + expect(clearUserCacheMock).toHaveBeenCalledWith(userAddress, 'all') + expect(fetchMultipleVaultsPPSMock).toHaveBeenCalled() + expect(fetchHistoricalPricesMock).toHaveBeenCalledWith( + [{ chainId: 1, address: tokenAddress, timestamps: [101, 201] }], + { resolution: 'utc_day' } + ) + expect(getShareBalanceAtTimestampMock).toHaveBeenNthCalledWith(1, [{ id: 'stale-entry' }], vaultAddress, 1, 101) + expect(getShareBalanceAtTimestampMock).toHaveBeenNthCalledWith(2, [{ id: 'stale-entry' }], vaultAddress, 1, 201) + expect(response.dataPoints).toEqual([ + { date: 'date-100', timestamp: 101, totalUsdValue: 6 }, + { date: 'date-200', timestamp: 201, totalUsdValue: 6 } + ]) + }) + + it('excludes hidden vaults from historical holdings totals', async () => { + const userAddress = '0x93a62da5a14c80f265dabc077fcee437b1a0efde' + const hiddenVaultAddress = '0x00000000000000000000000000000000000000c2' + + generateDailyTimestampsMock.mockReturnValue([100]) + timestampToDateStringMock.mockImplementation((timestamp: number) => `date-${timestamp}`) + getCachedTotalsWithTimestampMock.mockResolvedValue({ totals: [], oldestUpdatedAt: null }) + fetchUserEventsMock.mockResolvedValue({ + deposits: [], + withdrawals: [], + transfersIn: [], + transfersOut: [] + }) + buildPositionTimelineMock.mockReturnValue([{ id: 'hidden-entry' }]) + generateDailyTimestampsFromRangeMock.mockReturnValue([]) + getUniqueVaultsMock.mockReturnValue([{ chainId: 1, vaultAddress: hiddenVaultAddress }]) + fetchMultipleVaultsMetadataMock.mockResolvedValue( + new Map([ + [ + `1:${hiddenVaultAddress}`, + { + address: hiddenVaultAddress, + chainId: 1, + version: 'v3', + isHidden: true, + token: { + address: '0x0000000000000000000000000000000000000cc2', + symbol: 'HIDDEN', + decimals: 18 + }, + decimals: 18 + } + ] + ]) + ) + + const { getHistoricalHoldings } = await import('./aggregator') + const response = await getHistoricalHoldings(userAddress, 'all') + + expect(fetchMultipleVaultsPPSMock).not.toHaveBeenCalled() + expect(fetchHistoricalPricesMock).not.toHaveBeenCalled() + expect(response.dataPoints).toEqual([{ date: 'date-100', timestamp: 101, totalUsdValue: 0 }]) + }) + + it('expands all timeframe from the supported history start to the latest settled day', async () => { + const userAddress = '0x93a62da5a14c80f265dabc077fcee437b1a0efde' + const vaultAddress = '0x00000000000000000000000000000000000000b2' + const tokenAddress = '0x0000000000000000000000000000000000000bb2' + const timeline = [{ blockTimestamp: 50, blockNumber: 1 }] + const vaults = [{ chainId: 1, vaultAddress }] + + generateDailyTimestampsMock.mockReturnValue([100, 200]) + generateDailyTimestampsFromRangeMock.mockReturnValue([50, 100, 200]) + timestampToDateStringMock.mockImplementation((timestamp: number) => `date-${timestamp}`) + getCachedTotalsWithTimestampMock.mockResolvedValue({ totals: [], oldestUpdatedAt: null }) + fetchUserEventsMock.mockResolvedValue({ + deposits: [], + withdrawals: [], + transfersIn: [], + transfersOut: [] + }) + buildPositionTimelineMock.mockReturnValue(timeline) + getUniqueVaultsMock.mockReturnValue(vaults) + fetchMultipleVaultsMetadataMock.mockResolvedValue( + new Map([ + [ + `1:${vaultAddress}`, + { + address: vaultAddress, + chainId: 1, + version: 'v3', + token: { + address: tokenAddress, + symbol: 'TKN', + decimals: 18 + }, + decimals: 18 + } + ] + ]) + ) + fetchMultipleVaultsPPSMock.mockResolvedValue(new Map([[`1:${vaultAddress}`, new Map([[50, 1]])]])) + fetchHistoricalPricesMock.mockResolvedValue( + new Map([ + [ + `ethereum:${tokenAddress}`, + new Map([ + [50, 1], + [100, 1], + [200, 1] + ]) + ] + ]) + ) + getChainPrefixMock.mockReturnValue('ethereum') + getPPSMock.mockReturnValue(1) + getPriceAtTimestampMock.mockReturnValue(1) + getShareBalanceAtTimestampMock.mockReturnValue(1n * 10n ** 18n) + checkCacheStalenessMock.mockResolvedValue(false) + + const { getHistoricalHoldingsChart } = await import('./aggregator') + const response = await getHistoricalHoldingsChart(userAddress, 'all', 'parallel', 'all', 'usd', 'all') + + expect(generateDailyTimestampsFromRangeMock).toHaveBeenCalledWith(1_704_067_200, 200) + expect(getCachedTotalsWithTimestampMock).toHaveBeenCalledWith(userAddress, 'all', 'date-50', 'date-200') + expect(saveCachedTotalsMock).toHaveBeenCalledWith(userAddress, 'all', [ + { date: 'date-50', usdValue: 1 }, + { date: 'date-100', usdValue: 1 }, + { date: 'date-200', usdValue: 1 } + ]) + expect(response.timeframe).toBe('all') + expect(response.hasActivity).toBe(true) + expect(response.dataPoints).toEqual([ + { date: 'date-50', timestamp: 51, value: 1 }, + { date: 'date-100', timestamp: 101, value: 1 }, + { date: 'date-200', timestamp: 201, value: 1 } + ]) + }) + + it('does not cache recalculated totals after partial price fetch failures', async () => { + const userAddress = '0x93a62da5a14c80f265dabc077fcee437b1a0efde' + const vaultAddress = '0x00000000000000000000000000000000000000b2' + const tokenAddress = '0x0000000000000000000000000000000000000bb2' + const timeline = [{ blockTimestamp: 100, blockNumber: 1 }] + const vaults = [{ chainId: 1, vaultAddress }] + + generateDailyTimestampsMock.mockReturnValue([100]) + timestampToDateStringMock.mockImplementation((timestamp: number) => `date-${timestamp}`) + getCachedTotalsWithTimestampMock.mockResolvedValue({ totals: [], oldestUpdatedAt: null }) + fetchUserEventsMock.mockResolvedValue({ + deposits: [], + withdrawals: [], + transfersIn: [], + transfersOut: [] + }) + buildPositionTimelineMock.mockReturnValue(timeline) + getUniqueVaultsMock.mockReturnValue(vaults) + fetchMultipleVaultsMetadataMock.mockResolvedValue( + new Map([ + [ + `1:${vaultAddress}`, + { + address: vaultAddress, + chainId: 1, + version: 'v3', + token: { + address: tokenAddress, + symbol: 'TKN', + decimals: 18 + }, + decimals: 18 + } + ] + ]) + ) + fetchMultipleVaultsPPSMock.mockResolvedValue(new Map([[`1:${vaultAddress}`, new Map([[100, 1]])]])) + fetchHistoricalPricesMock.mockResolvedValue(new Map([[`ethereum:${tokenAddress}`, new Map([[101, 1]])]])) + getHistoricalPriceFetchFailedBatchesMock.mockReturnValue(1) + getChainPrefixMock.mockReturnValue('ethereum') + getPPSMock.mockReturnValue(1) + getPriceAtTimestampMock.mockReturnValue(1) + getShareBalanceAtTimestampMock.mockReturnValue(1n * 10n ** 18n) + generateDailyTimestampsFromRangeMock.mockReturnValue([]) + + const { getHistoricalHoldings } = await import('./aggregator') + const response = await getHistoricalHoldings(userAddress, 'all') + + expect(saveCachedTotalsMock).not.toHaveBeenCalled() + expect(response.dataPoints).toEqual([{ date: 'date-100', timestamp: 101, totalUsdValue: 1 }]) + }) + + it('marks history as active even when only unsettled same-day events exist', async () => { + const userAddress = '0x93a62da5a14c80f265dabc077fcee437b1a0efde' + const vaultAddress = '0x00000000000000000000000000000000000000e1' + const sameDayDeposit = { + id: 'same-day-deposit', + vaultAddress, + chainId: 1, + blockNumber: 2, + blockTimestamp: 250, + logIndex: 0, + transactionHash: '0x123', + transactionFrom: userAddress, + owner: userAddress, + sender: userAddress, + shares: '1000000000000000000', + assets: '1000000000000000000' + } + + generateDailyTimestampsMock.mockReturnValue([100, 200]) + timestampToDateStringMock.mockImplementation((timestamp: number) => `date-${timestamp}`) + getCachedTotalsWithTimestampMock.mockResolvedValue({ totals: [], oldestUpdatedAt: null }) + fetchUserEventsMock.mockImplementation((_address: string, _version: string, maxTimestamp?: number) => + Promise.resolve({ + deposits: (maxTimestamp ?? 0) >= sameDayDeposit.blockTimestamp ? [sameDayDeposit] : [], + withdrawals: [], + transfersIn: [], + transfersOut: [] + }) + ) + buildPositionTimelineMock.mockImplementation((deposits: Array<{ blockTimestamp: number; blockNumber: number }>) => { + return deposits.map((deposit) => ({ + blockTimestamp: deposit.blockTimestamp, + blockNumber: deposit.blockNumber + })) + }) + getUniqueVaultsMock.mockReturnValue([]) + generateDailyTimestampsFromRangeMock.mockReturnValue([]) + + const { getHistoricalHoldingsChart } = await import('./aggregator') + const response = await getHistoricalHoldingsChart(userAddress, 'all', 'seq', 'paged', 'usd', '1y') + + expect(fetchUserEventsMock).toHaveBeenCalledWith( + userAddress, + 'all', + 201 + CURRENT_DAY_LOOKAHEAD_SECONDS, + 'seq', + 'paged' + ) + expect(response.hasActivity).toBe(true) + expect(response.dataPoints).toEqual([ + { date: 'date-100', timestamp: 101, value: 0 }, + { date: 'date-200', timestamp: 201, value: 0 } + ]) + }) + + it('builds breakdown using the latest chart timestamp instead of current time', async () => { + vi.spyOn(Date, 'now').mockReturnValue(999_000) + + const userAddress = '0x93a62da5a14c80f265dabc077fcee437b1a0efde' + const vaultAddress = '0x00000000000000000000000000000000000000a2' + const tokenAddress = '0x0000000000000000000000000000000000000aa2' + const timeline = [{ id: 'entry-1' }] + const vaults = [{ chainId: 1, vaultAddress }] + + generateDailyTimestampsMock.mockReturnValue([100, 200]) + timestampToDateStringMock.mockImplementation((timestamp: number) => `date-${timestamp}`) + fetchUserEventsMock.mockResolvedValue({ + deposits: [], + withdrawals: [], + transfersIn: [], + transfersOut: [] + }) + buildPositionTimelineMock.mockReturnValue(timeline) + getUniqueVaultsMock.mockReturnValue(vaults) + fetchMultipleVaultsMetadataMock.mockResolvedValue( + new Map([ + [ + `1:${vaultAddress}`, + { + address: vaultAddress, + chainId: 1, + version: 'v3', + token: { + address: tokenAddress, + symbol: 'TKN', + decimals: 18 + }, + decimals: 18 + } + ] + ]) + ) + fetchMultipleVaultsPPSMock.mockResolvedValue(new Map([[`1:${vaultAddress}`, new Map([[201, 1.5]])]])) + fetchHistoricalPricesMock.mockResolvedValue(new Map([[`ethereum:${tokenAddress}`, new Map([[200, 2]])]])) + getChainPrefixMock.mockReturnValue('ethereum') + getPPSMock.mockReturnValue(1.5) + getPriceAtTimestampMock.mockReturnValue(2) + getShareBalanceAtTimestampMock.mockReturnValue(2n * 10n ** 18n) + + const { getHoldingsBreakdown } = await import('./aggregator') + const response = await getHoldingsBreakdown(userAddress, 'all', 'parallel', 'all') + + expect(fetchUserEventsMock).toHaveBeenCalledWith(userAddress, 'all', 86600, 'parallel', 'all') + expect(fetchHistoricalPricesMock).toHaveBeenCalledWith([{ chainId: 1, address: tokenAddress, timestamps: [201] }], { + resolution: 'utc_day' + }) + expect(getShareBalanceAtTimestampMock).toHaveBeenCalledWith(timeline, vaultAddress, 1, 201) + expect(response).toEqual({ + address: userAddress, + version: 'all', + date: 'date-201', + timestamp: 201, + summary: { + totalVaults: 1, + vaultsWithShares: 1, + totalUsdValue: 6, + missingMetadata: 0, + missingPps: 0, + missingPrice: 0 + }, + vaults: [ + { + chainId: 1, + vaultAddress, + shares: '2000000000000000000', + sharesFormatted: 2, + pricePerShare: 1.5, + tokenPrice: 2, + usdValue: 6, + metadata: { + symbol: 'TKN', + decimals: 18, + tokenAddress + }, + status: 'ok' + } + ], + issues: { + missingMetadata: [], + missingPps: [], + missingPrice: [] + } + }) + }) + + it('builds breakdown for an explicitly requested historical date', async () => { + const userAddress = '0x93a62da5a14c80f265dabc077fcee437b1a0efde' + const vaultAddress = '0x00000000000000000000000000000000000000b2' + const tokenAddress = '0x0000000000000000000000000000000000000bb2' + const timeline = [{ id: 'entry-2' }] + const vaults = [{ chainId: 1, vaultAddress }] + + generateDailyTimestampsMock.mockReturnValue([100, 200]) + timestampToDateStringMock.mockImplementation((timestamp: number) => `date-${timestamp}`) + fetchUserEventsMock.mockResolvedValue({ + deposits: [], + withdrawals: [], + transfersIn: [], + transfersOut: [] + }) + buildPositionTimelineMock.mockReturnValue(timeline) + getUniqueVaultsMock.mockReturnValue(vaults) + fetchMultipleVaultsMetadataMock.mockResolvedValue( + new Map([ + [ + `1:${vaultAddress}`, + { + address: vaultAddress, + chainId: 1, + version: 'v3', + token: { + address: tokenAddress, + symbol: 'OLD', + decimals: 18 + }, + decimals: 18 + } + ] + ]) + ) + fetchMultipleVaultsPPSMock.mockResolvedValue(new Map([[`1:${vaultAddress}`, new Map([[101, 3]])]])) + fetchHistoricalPricesMock.mockResolvedValue(new Map([[`ethereum:${tokenAddress}`, new Map([[100, 4]])]])) + getChainPrefixMock.mockReturnValue('ethereum') + getPPSMock.mockReturnValue(3) + getPriceAtTimestampMock.mockReturnValue(4) + getShareBalanceAtTimestampMock.mockReturnValue(5n * 10n ** 18n) + + const { getHoldingsBreakdown } = await import('./aggregator') + const response = await getHoldingsBreakdown(userAddress, 'all', 'seq', 'paged', 100) + + expect(fetchUserEventsMock).toHaveBeenCalledWith(userAddress, 'all', 86500, 'seq', 'paged') + expect(fetchHistoricalPricesMock).toHaveBeenCalledWith([{ chainId: 1, address: tokenAddress, timestamps: [101] }], { + resolution: 'utc_day' + }) + expect(getShareBalanceAtTimestampMock).toHaveBeenCalledWith(timeline, vaultAddress, 1, 101) + expect(response.date).toBe('date-101') + expect(response.timestamp).toBe(101) + expect(response.summary.totalUsdValue).toBe(60) + }) +}) diff --git a/api/lib/holdings/services/aggregator.ts b/api/lib/holdings/services/aggregator.ts new file mode 100644 index 000000000..1dd918531 --- /dev/null +++ b/api/lib/holdings/services/aggregator.ts @@ -0,0 +1,803 @@ +import { holdingsConfig } from '../config' +import type { VaultMetadata } from '../types' +import type { CachedTotal } from './cache' +import { checkCacheStaleness, clearUserCache, getCachedTotalsWithTimestamp, saveCachedTotals } from './cache' +import { debugLog, reportHoldingsProgress } from './debug' +import { + fetchHistoricalPrices, + fetchHistoricalPricesForTokenTimestamps, + getChainPrefix, + getHistoricalPriceFetchFailedBatches, + getPriceAtTimestamp +} from './defillama' +import { + fetchUserEvents, + type HoldingsEventFetchType, + type HoldingsEventPaginationMode, + type VaultVersion +} from './graphql' +import { + buildPositionTimeline, + generateDailyTimestamps, + generateDailyTimestampsFromRange, + getShareBalanceAtTimestamp, + getUniqueVaults, + timestampToDateString, + toSettledDayTimestamp +} from './holdings' +import { fetchMultipleVaultsPPS, getPPS } from './kong' +import { + deriveNestedVaultAssetPriceData, + expandNestedVaultAssetPriceRequests, + getNestedVaultPpsIdentifiersFromPriceRequests, + mergeVaultIdentifiers, + resolveNestedVaultAssetMetadata +} from './nestedVaultPrices' +import { toVaultKey } from './pnlShared' +import { getSettledAddressScopedContext, getSettledVersionedPpsContext } from './settledHoldingsContext' +import { fetchMultipleVaultsMetadata } from './vaults' + +export interface HoldingsHistoryResponse { + address: string + periodDays: number + timeframe: HoldingsHistoryTimeframe + hasActivity: boolean + dataPoints: Array<{ date: string; timestamp: number; totalUsdValue: number }> +} + +export type HoldingsHistoryDenomination = 'usd' | 'eth' +export type HoldingsHistoryTimeframe = '1y' | 'all' +export type HoldingsVaultFilter = { chainId: number; vaultAddress: string } + +export interface HoldingsHistoryChartResponse { + address: string + periodDays: number + timeframe: HoldingsHistoryTimeframe + denomination: HoldingsHistoryDenomination + hasActivity: boolean + dataPoints: Array<{ date: string; timestamp: number; value: number }> +} + +const ETHEREUM_WETH_ADDRESS = '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2' + +export interface HoldingsBreakdownVaultResponse { + chainId: number + vaultAddress: string + shares: string + sharesFormatted: number + pricePerShare: number | null + tokenPrice: number | null + usdValue: number | null + metadata: { + symbol: string + decimals: number + tokenAddress: string + } | null + status: 'ok' | 'missing_metadata' | 'missing_pps' | 'missing_price' +} + +export interface HoldingsBreakdownResponse { + address: string + version: VaultVersion + date: string + timestamp: number + summary: { + totalVaults: number + vaultsWithShares: number + totalUsdValue: number + missingMetadata: number + missingPps: number + missingPrice: number + } + vaults: HoldingsBreakdownVaultResponse[] + issues: { + missingMetadata: string[] + missingPps: string[] + missingPrice: string[] + } + message?: string +} + +export function getHoldingsTotalsCacheVersion(version: VaultVersion): string { + return version +} + +function filterVaultsByAuthoritativeVersion< + TVault extends { + chainId: number + vaultAddress: string + } +>(vaults: TVault[], vaultMetadata: Map, version: VaultVersion): TVault[] { + return vaults.filter((vault) => { + const metadata = vaultMetadata.get(toVaultKey(vault.chainId, vault.vaultAddress)) + + if (metadata?.isHidden) { + return false + } + + if (version === 'all') { + return true + } + + return metadata?.version === version + }) +} + +function filterVaultsByRequestedVault( + vaults: TVault[], + requestedVaults?: HoldingsVaultFilter[] +): TVault[] { + if (!requestedVaults?.length) { + return vaults + } + + const requestedVaultKeys = new Set( + requestedVaults.map((vault) => toVaultKey(vault.chainId, vault.vaultAddress.toLowerCase())) + ) + return vaults.filter((vault) => requestedVaultKeys.has(toVaultKey(vault.chainId, vault.vaultAddress))) +} + +function buildEmptyBreakdownResponse( + userAddress: string, + version: VaultVersion, + timestamp: number, + message: string +): HoldingsBreakdownResponse { + return { + address: userAddress, + version, + date: timestampToDateString(timestamp), + timestamp, + summary: { + totalVaults: 0, + vaultsWithShares: 0, + totalUsdValue: 0, + missingMetadata: 0, + missingPps: 0, + missingPrice: 0 + }, + vaults: [], + issues: { + missingMetadata: [], + missingPps: [], + missingPrice: [] + }, + message + } +} + +export async function getHistoricalHoldings( + userAddress: string, + version: VaultVersion = 'all', + fetchType: HoldingsEventFetchType = 'seq', + paginationMode: HoldingsEventPaginationMode = 'paged', + timeframe: HoldingsHistoryTimeframe = '1y', + requestedVaults?: HoldingsVaultFilter[] +): Promise { + const defaultDays = holdingsConfig.historyDays + const baseContext = await getSettledAddressScopedContext({ + userAddress, + fetchType, + paginationMode + }) + reportHoldingsProgress(18, 'Loaded wallet events', null) + const dayTimestamps = generateDailyTimestamps(defaultDays, 1) + const latestSettledDayTimestamp = baseContext.latestSettledDayTimestamp + const timestamps = + timeframe === 'all' + ? generateDailyTimestampsFromRange(holdingsConfig.historyStartTimestamp, latestSettledDayTimestamp) + : dayTimestamps + const periodDays = timestamps.length + debugLog('history', 'starting historical holdings aggregation', { + version, + fetchType, + paginationMode, + timeframe, + days: periodDays, + timestamps: timestamps.length, + latestSettledDate: timestampToDateString(latestSettledDayTimestamp) + }) + + // Fetch cached totals with timestamp info for staleness check + let cachedTotals: CachedTotal[] = [] + let oldestUpdatedAt: Date | null = null + const cacheVersion = getHoldingsTotalsCacheVersion(version) + const shouldReadCache = timestamps.length > 0 && !requestedVaults?.length + const shouldWriteCache = timestamps.length > 0 && !requestedVaults?.length + if (shouldReadCache) { + const startDate = timestampToDateString(timestamps[0]) + const endDate = timestampToDateString(timestamps[timestamps.length - 1]) + const cachedResult = await getCachedTotalsWithTimestamp(userAddress, cacheVersion, startDate, endDate) + cachedTotals = cachedResult.totals + oldestUpdatedAt = cachedResult.oldestUpdatedAt + } + debugLog('history', 'loaded cached totals for request', { + version, + timeframe, + cachedTotals: cachedTotals.length, + oldestUpdatedAt: oldestUpdatedAt?.toISOString() ?? null + }) + reportHoldingsProgress(28, 'Checked cached historical totals', `${cachedTotals.length} cached days`) + + let cachedByDate = new Map(cachedTotals.map((total) => [total.date, total.usdValue])) + + const timeline = baseContext.timeline + const hasActivity = baseContext.hasActivity + debugLog('history', 'built position timeline', { + fetchType, + paginationMode, + deposits: baseContext.events.deposits.length, + withdrawals: baseContext.events.withdrawals.length, + transfersIn: baseContext.events.transfersIn.length, + transfersOut: baseContext.events.transfersOut.length, + timelineEntries: timeline.length + }) + reportHoldingsProgress(36, 'Built historical position timeline', `${timeline.length} timeline entries`) + + const vaultMetadata = baseContext.vaultMetadata + const versionFilteredVaults = filterVaultsByAuthoritativeVersion( + baseContext.rawVaultIdentifiers, + vaultMetadata, + version + ) + const vaults = filterVaultsByRequestedVault(versionFilteredVaults, requestedVaults) + debugLog('history', 'resolved authoritative vault versions for history', { + version, + fetchType, + paginationMode, + rawVaults: baseContext.rawVaultIdentifiers.length, + filteredVaults: vaults.length, + metadataResolved: vaultMetadata.size + }) + reportHoldingsProgress(44, 'Resolved vault metadata', `${vaults.length} vaults`) + + // Check if any vaults have been invalidated since cache was written + if (shouldReadCache && cachedTotals.length > 0 && vaults.length > 0) { + const vaultIdentifiers = vaults.map((v) => ({ address: v.vaultAddress, chainId: v.chainId })) + const isStale = await checkCacheStaleness(vaultIdentifiers, oldestUpdatedAt) + debugLog('history', 'completed cache staleness check', { + version, + fetchType, + paginationMode, + vaults: vaultIdentifiers.length, + isStale + }) + + if (isStale) { + console.log(`[Aggregator] Cache stale for ${userAddress}, clearing and recalculating`) + await clearUserCache(userAddress, cacheVersion) + cachedTotals = [] + oldestUpdatedAt = null + cachedByDate = new Map() + } + } + + const hasFullCacheCoverage = + timestamps.length > 0 && timestamps.every((timestamp) => cachedByDate.has(timestampToDateString(timestamp))) + + if (hasFullCacheCoverage) { + const dataPoints = timestamps.map((timestamp) => ({ + date: timestampToDateString(timestamp), + timestamp: toSettledDayTimestamp(timestamp), + totalUsdValue: cachedByDate.get(timestampToDateString(timestamp)) ?? 0 + })) + debugLog('history', 'serving fully cached historical holdings', { + version, + dataPoints: dataPoints.length, + oldestUpdatedAt: oldestUpdatedAt?.toISOString() ?? null + }) + reportHoldingsProgress(94, 'Loaded cached historical chart data', `${dataPoints.length} chart points`) + + return { + address: userAddress, + periodDays, + timeframe, + hasActivity, + dataPoints + } + } + + const missingTimestamps = timestamps.filter((ts) => !cachedByDate.has(timestampToDateString(ts))) + debugLog('history', 'computed missing timestamps', { + fetchType, + paginationMode, + cachedDates: cachedByDate.size, + missingTimestamps: missingTimestamps.length + }) + reportHoldingsProgress(52, 'Computed missing historical days', `${missingTimestamps.length} days need valuation`) + + const newTotals: CachedTotal[] = [] + let failedPriceBatches = 0 + + if (missingTimestamps.length > 0) { + // Events already fetched above + + if (timeline.length === 0) { + debugLog('history', 'timeline empty, returning zero holdings history') + // No holdings - return zeros without caching to prevent DB spam + return { + address: userAddress, + periodDays, + timeframe, + hasActivity, + dataPoints: timestamps.map((ts) => ({ + date: timestampToDateString(ts), + timestamp: toSettledDayTimestamp(ts), + totalUsdValue: 0 + })) + } + } else if (vaults.length === 0) { + debugLog('history', 'no vaults matched the requested authoritative version, returning zero holdings history', { + version, + fetchType, + paginationMode + }) + return { + address: userAddress, + periodDays, + timeframe, + hasActivity, + dataPoints: timestamps.map((ts) => ({ + date: timestampToDateString(ts), + timestamp: toSettledDayTimestamp(ts), + totalUsdValue: 0 + })) + } + } else { + const ppsContext = await getSettledVersionedPpsContext({ + userAddress, + version, + fetchType, + paginationMode, + vaultIdentifiers: vaults, + context: baseContext + }) + reportHoldingsProgress(62, 'Loaded vault share price history', `${vaults.length} vaults`) + const underlyingTokens = Array.from( + vaults + .reduce>((tokens, vault) => { + const metadata = vaultMetadata.get(toVaultKey(vault.chainId, vault.vaultAddress)) + + if (!metadata) { + return tokens + } + + const tokenKey = `${metadata.chainId}:${metadata.token.address.toLowerCase()}` + if (!tokens.has(tokenKey)) { + tokens.set(tokenKey, { + chainId: metadata.chainId, + address: metadata.token.address + }) + } + + return tokens + }, new Map()) + .values() + ) + const valuationTimestamps = missingTimestamps.map((timestamp) => toSettledDayTimestamp(timestamp)) + const basePriceRequests = underlyingTokens.map((token) => ({ + ...token, + timestamps: valuationTimestamps + })) + const priceRequests = expandNestedVaultAssetPriceRequests(basePriceRequests, vaultMetadata) + const ppsIdentifiers = mergeVaultIdentifiers([ + ...vaults, + ...getNestedVaultPpsIdentifiersFromPriceRequests(basePriceRequests, vaultMetadata) + ]) + const fetchedPriceData = await fetchHistoricalPricesForTokenTimestamps(priceRequests, { resolution: 'utc_day' }) + failedPriceBatches = getHistoricalPriceFetchFailedBatches(fetchedPriceData) + reportHoldingsProgress(76, 'Fetched historical token prices', `${priceRequests.length} price series`) + const priceData = deriveNestedVaultAssetPriceData({ + priceData: fetchedPriceData, + priceRequests, + vaultMetadata, + ppsData: ppsContext.ppsData + }) + debugLog('history', 'resolved metadata and PPS for history', { + version, + fetchType, + paginationMode, + vaults: ppsIdentifiers.length, + metadataResolved: vaultMetadata.size, + ppsResolved: ppsContext.ppsData.size, + emptyPpsTimelines: Array.from(ppsContext.ppsData.values()).filter((timeline) => timeline.size === 0).length + }) + debugLog('history', 'resolved historical token prices', { + version, + fetchType, + paginationMode, + tokens: priceRequests.length, + priceKeys: priceData.size, + missingTimestamps: missingTimestamps.length, + failedPriceBatches + }) + + for (const timestamp of missingTimestamps) { + const valuationTimestamp = toSettledDayTimestamp(timestamp) + let dayTotal = 0 + + for (const vault of vaults) { + const vaultKey = `${vault.chainId}:${vault.vaultAddress}` + const metadata = vaultMetadata.get(vaultKey) + + if (!metadata) continue + + const shares = getShareBalanceAtTimestamp(timeline, vault.vaultAddress, vault.chainId, valuationTimestamp) + + if (shares === BigInt(0)) continue + + const ppsMap = ppsContext.ppsData.get(vaultKey) + const pps = ppsMap ? getPPS(ppsMap, valuationTimestamp) : null + + if (pps === null) continue + + const priceKey = `${getChainPrefix(vault.chainId)}:${metadata.token.address.toLowerCase()}` + const tokenPriceMap = priceData.get(priceKey) + const tokenPrice = tokenPriceMap ? getPriceAtTimestamp(tokenPriceMap, valuationTimestamp) : 0 + + const sharesFloat = Number(shares) / 10 ** metadata.decimals + const usdValue = sharesFloat * pps * tokenPrice + + dayTotal += usdValue + } + + newTotals.push({ date: timestampToDateString(timestamp), usdValue: dayTotal }) + } + + debugLog('history', 'calculated uncached daily totals', { + version, + fetchType, + paginationMode, + newTotals: newTotals.length, + nonZeroTotals: newTotals.filter((total) => total.usdValue > 0).length + }) + reportHoldingsProgress(88, 'Calculated uncached chart history', `${newTotals.length} daily totals`) + } + + if (shouldWriteCache && newTotals.length > 0 && failedPriceBatches === 0) { + const savedTotals = await saveCachedTotals(userAddress, cacheVersion, newTotals) + debugLog( + 'history', + savedTotals ? 'saved recalculated totals to cache' : 'did not save recalculated totals to cache', + { + version, + fetchType, + paginationMode, + newTotals: newTotals.length + } + ) + reportHoldingsProgress( + 92, + savedTotals ? 'Saved historical chart cache' : 'Skipped historical chart cache save', + `${newTotals.length} daily totals` + ) + } else if (shouldWriteCache && newTotals.length > 0 && failedPriceBatches > 0) { + debugLog('history', 'skipped historical totals cache save because price batches failed', { + version, + fetchType, + paginationMode, + newTotals: newTotals.length, + failedPriceBatches + }) + reportHoldingsProgress(92, 'Skipped historical chart cache save', `${failedPriceBatches} price batches failed`) + } + } + + // Merge cached and new totals + for (const total of newTotals) { + cachedByDate.set(total.date, total.usdValue) + } + + const dataPoints = timestamps.map((ts) => ({ + date: timestampToDateString(ts), + timestamp: toSettledDayTimestamp(ts), + totalUsdValue: cachedByDate.get(timestampToDateString(ts)) ?? 0 + })) + debugLog('history', 'completed historical holdings aggregation', { + version, + fetchType, + paginationMode, + dataPoints: dataPoints.length, + nonZeroPoints: dataPoints.filter((point) => point.totalUsdValue > 0).length + }) + reportHoldingsProgress(96, 'Prepared historical chart data', `${dataPoints.length} chart points`) + + return { + address: userAddress, + periodDays, + timeframe, + hasActivity, + dataPoints + } +} + +export async function getHistoricalHoldingsChart( + userAddress: string, + version: VaultVersion = 'all', + fetchType: HoldingsEventFetchType = 'seq', + paginationMode: HoldingsEventPaginationMode = 'paged', + denomination: HoldingsHistoryDenomination = 'usd', + timeframe: HoldingsHistoryTimeframe = '1y', + requestedVaults?: HoldingsVaultFilter[] +): Promise { + const holdings = await getHistoricalHoldings( + userAddress, + version, + fetchType, + paginationMode, + timeframe, + requestedVaults + ) + + if (denomination === 'usd') { + return { + address: holdings.address, + periodDays: holdings.periodDays, + timeframe: holdings.timeframe, + denomination, + hasActivity: holdings.hasActivity, + dataPoints: holdings.dataPoints.map((point) => ({ + date: point.date, + timestamp: point.timestamp, + value: point.totalUsdValue + })) + } + } + + const timestamps = holdings.dataPoints.map((point) => point.timestamp) + const ethPriceMap = await fetchHistoricalPrices([{ chainId: 1, address: ETHEREUM_WETH_ADDRESS }], timestamps) + const ethPrices = ethPriceMap.get(`${getChainPrefix(1)}:${ETHEREUM_WETH_ADDRESS.toLowerCase()}`) + + return { + address: holdings.address, + periodDays: holdings.periodDays, + timeframe: holdings.timeframe, + denomination, + hasActivity: holdings.hasActivity, + dataPoints: holdings.dataPoints.map((point) => { + const ethPriceUsd = ethPrices ? getPriceAtTimestamp(ethPrices, point.timestamp) : 0 + return { + date: point.date, + timestamp: point.timestamp, + value: ethPriceUsd > 0 ? point.totalUsdValue / ethPriceUsd : 0 + } + }) + } +} + +export async function getHoldingsBreakdown( + userAddress: string, + version: VaultVersion = 'all', + fetchType: HoldingsEventFetchType = 'seq', + paginationMode: HoldingsEventPaginationMode = 'paged', + targetTimestamp?: number +): Promise { + const timestamps = generateDailyTimestamps(holdingsConfig.historyDays, 1) + const breakdownDayTimestamp = targetTimestamp ?? timestamps[timestamps.length - 1] + const breakdownTimestamp = toSettledDayTimestamp(breakdownDayTimestamp) + const breakdownDate = timestampToDateString(breakdownTimestamp) + const breakdownPriceTimestamp = breakdownTimestamp + debugLog('breakdown', 'starting holdings breakdown', { + version, + fetchType, + paginationMode, + timestamp: breakdownTimestamp, + date: breakdownDate, + priceTimestamp: breakdownPriceTimestamp + }) + + const maxTimestamp = breakdownDayTimestamp + 86400 + const events = await fetchUserEvents(userAddress, 'all', maxTimestamp, fetchType, paginationMode) + const timeline = buildPositionTimeline(events.deposits, events.withdrawals, events.transfersIn, events.transfersOut) + debugLog('breakdown', 'built position timeline for breakdown', { + version, + fetchType, + paginationMode, + deposits: events.deposits.length, + withdrawals: events.withdrawals.length, + transfersIn: events.transfersIn.length, + transfersOut: events.transfersOut.length, + timelineEntries: timeline.length + }) + + if (timeline.length === 0) { + debugLog('breakdown', 'no events found for holdings breakdown', { + version, + fetchType, + paginationMode + }) + return buildEmptyBreakdownResponse(userAddress, version, breakdownTimestamp, 'No events found') + } + + const rawVaults = getUniqueVaults(timeline) + const baseVaultMetadata = rawVaults.length > 0 ? await fetchMultipleVaultsMetadata(rawVaults) : new Map() + const vaultMetadata = await resolveNestedVaultAssetMetadata(baseVaultMetadata) + const vaults = filterVaultsByAuthoritativeVersion(rawVaults, vaultMetadata, version) + debugLog('breakdown', 'resolved authoritative vault versions for breakdown', { + version, + fetchType, + paginationMode, + rawVaults: rawVaults.length, + filteredVaults: vaults.length, + metadataResolved: vaultMetadata.size + }) + + if (vaults.length === 0) { + debugLog('breakdown', 'no vaults matched the requested authoritative version for breakdown', { + version, + fetchType, + paginationMode + }) + return buildEmptyBreakdownResponse(userAddress, version, breakdownTimestamp, 'No matching holdings found') + } + + const activeVaults = vaults.reduce< + Array<{ + chainId: number + vaultAddress: string + shares: bigint + sharesFormatted: number + }> + >((active, vault) => { + const metadata = vaultMetadata.get(toVaultKey(vault.chainId, vault.vaultAddress)) + const decimals = metadata?.decimals ?? 18 + const shares = getShareBalanceAtTimestamp(timeline, vault.vaultAddress, vault.chainId, breakdownTimestamp) + + if (shares <= BigInt(0)) { + return active + } + + active.push({ + chainId: vault.chainId, + vaultAddress: vault.vaultAddress, + shares, + sharesFormatted: Number(shares) / 10 ** decimals + }) + return active + }, []) + + const seenTokens = new Set() + const underlyingTokens: Array<{ chainId: number; address: string }> = [] + for (const vault of activeVaults) { + const metadata = vaultMetadata.get(toVaultKey(vault.chainId, vault.vaultAddress)) + if (!metadata) { + continue + } + + const tokenKey = `${metadata.chainId}:${metadata.token.address.toLowerCase()}` + if (!seenTokens.has(tokenKey)) { + seenTokens.add(tokenKey) + underlyingTokens.push({ + chainId: metadata.chainId, + address: metadata.token.address + }) + } + } + + const basePriceRequests = underlyingTokens.map((token) => ({ + ...token, + timestamps: [breakdownPriceTimestamp] + })) + const priceRequests = expandNestedVaultAssetPriceRequests(basePriceRequests, vaultMetadata) + const ppsIdentifiers = mergeVaultIdentifiers([ + ...activeVaults, + ...getNestedVaultPpsIdentifiersFromPriceRequests(basePriceRequests, vaultMetadata) + ]) + const [ppsData, fetchedPriceData] = await Promise.all([ + ppsIdentifiers.length > 0 ? fetchMultipleVaultsPPS(ppsIdentifiers) : Promise.resolve(new Map()), + priceRequests.length > 0 + ? fetchHistoricalPricesForTokenTimestamps(priceRequests, { resolution: 'utc_day' }) + : Promise.resolve(new Map()) + ]) + const priceData = deriveNestedVaultAssetPriceData({ + priceData: fetchedPriceData, + priceRequests, + vaultMetadata, + ppsData + }) + debugLog('breakdown', 'resolved metadata, PPS, and prices for breakdown', { + version, + fetchType, + paginationMode, + vaults: ppsIdentifiers.length, + metadataResolved: vaultMetadata.size, + ppsResolved: ppsData.size, + tokens: priceRequests.length, + priceKeys: priceData.size, + timestamp: breakdownTimestamp, + priceTimestamp: breakdownPriceTimestamp, + activeVaults: activeVaults.length + }) + + const results: HoldingsBreakdownVaultResponse[] = [] + + for (const vault of activeVaults) { + const vaultKey = toVaultKey(vault.chainId, vault.vaultAddress) + const metadata = vaultMetadata.get(vaultKey) + const ppsMap = ppsData.get(vaultKey) + const pps = ppsMap ? getPPS(ppsMap, breakdownTimestamp) : null + + let tokenPrice: number | null = null + let usdValue: number | null = null + + if (metadata) { + const priceKey = `${getChainPrefix(metadata.chainId)}:${metadata.token.address.toLowerCase()}` + const tokenPriceMap = priceData.get(priceKey) + tokenPrice = tokenPriceMap ? getPriceAtTimestamp(tokenPriceMap, breakdownPriceTimestamp) : 0 + usdValue = pps ? vault.sharesFormatted * pps * tokenPrice : 0 + } + + let status: HoldingsBreakdownVaultResponse['status'] = 'ok' + if (!metadata) { + status = 'missing_metadata' + } else if (!pps) { + status = 'missing_pps' + } else if (tokenPrice === 0) { + status = 'missing_price' + } + + results.push({ + chainId: vault.chainId, + vaultAddress: vault.vaultAddress, + shares: vault.shares.toString(), + sharesFormatted: vault.sharesFormatted, + pricePerShare: pps, + tokenPrice, + usdValue, + metadata: metadata + ? { + symbol: metadata.token.symbol, + decimals: metadata.decimals, + tokenAddress: metadata.token.address + } + : null, + status + }) + } + + results.sort((a, b) => (b.usdValue ?? 0) - (a.usdValue ?? 0)) + + const withShares = results.filter((vault) => vault.sharesFormatted > 0) + const missingMetadata = results.filter((vault) => vault.status === 'missing_metadata') + const missingPps = results.filter((vault) => vault.status === 'missing_pps') + const missingPrice = results.filter((vault) => vault.status === 'missing_price') + const totalUsdValue = withShares.reduce((sum, vault) => sum + (vault.usdValue ?? 0), 0) + + debugLog('breakdown', 'completed holdings breakdown', { + version, + fetchType, + paginationMode, + timestamp: breakdownTimestamp, + totalVaults: vaults.length, + vaultsWithShares: withShares.length, + totalUsdValue, + missingMetadata: missingMetadata.length, + missingPps: missingPps.length, + missingPrice: missingPrice.length + }) + + return { + address: userAddress, + version, + date: breakdownDate, + timestamp: breakdownTimestamp, + summary: { + totalVaults: vaults.length, + vaultsWithShares: withShares.length, + totalUsdValue, + missingMetadata: missingMetadata.length, + missingPps: missingPps.length, + missingPrice: missingPrice.length + }, + vaults: withShares, + issues: { + missingMetadata: missingMetadata.map((vault) => `${vault.chainId}:${vault.vaultAddress}`), + missingPps: missingPps + .filter((vault) => vault.sharesFormatted > 0) + .map((vault) => `${vault.chainId}:${vault.vaultAddress}`), + missingPrice: missingPrice + .filter((vault) => vault.sharesFormatted > 0) + .map((vault) => `${vault.chainId}:${vault.vaultAddress}`) + } + } +} diff --git a/api/lib/holdings/services/cache.test.ts b/api/lib/holdings/services/cache.test.ts new file mode 100644 index 000000000..d27159ef8 --- /dev/null +++ b/api/lib/holdings/services/cache.test.ts @@ -0,0 +1,66 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const getHoldingsRedisClientMock = vi.fn() +const isHoldingsStorageEnabledMock = vi.fn() +const handleHoldingsRedisErrorMock = vi.fn() + +vi.mock('../storage/redis', () => ({ + getHoldingsRedisClient: getHoldingsRedisClientMock, + isHoldingsStorageEnabled: isHoldingsStorageEnabledMock, + handleHoldingsRedisError: handleHoldingsRedisErrorMock +})) + +describe('Redis cache writes', () => { + beforeEach(() => { + vi.resetModules() + vi.clearAllMocks() + }) + + it('saves historical totals as Redis hash fields with a cache ttl', async () => { + const hsetMock = vi.fn().mockResolvedValue(2) + const expireMock = vi.fn().mockResolvedValue(1) + isHoldingsStorageEnabledMock.mockReturnValue(true) + getHoldingsRedisClientMock.mockReturnValue({ + hset: hsetMock, + expire: expireMock + }) + + const { saveCachedTotals } = await import('./cache') + const saved = await saveCachedTotals('0x0000000000000000000000000000000000000001', 'all', [ + { date: '2025-01-01', usdValue: 1 }, + { date: '2025-01-02', usdValue: 2 } + ]) + + expect(saved).toBe(true) + expect(hsetMock).toHaveBeenCalledTimes(1) + expect(hsetMock.mock.calls[0]?.[0]).toMatch(/^holdings:totals:[a-f0-9]{64}:all$/) + expect(Object.keys(hsetMock.mock.calls[0]?.[1] ?? {})).toEqual(['2025-01-01', '2025-01-02']) + expect(expireMock).toHaveBeenCalledWith(hsetMock.mock.calls[0]?.[0], 30 * 24 * 60 * 60) + }) + + it('loads cached totals by requested date range', async () => { + const hgetallMock = vi.fn().mockResolvedValue({ + '2025-01-01': JSON.stringify({ usdValue: 1, updatedAt: 1000 }), + '2025-01-02': JSON.stringify({ usdValue: 2, updatedAt: 2000 }), + '2025-01-03': JSON.stringify({ usdValue: 3, updatedAt: 3000 }) + }) + isHoldingsStorageEnabledMock.mockReturnValue(true) + getHoldingsRedisClientMock.mockReturnValue({ + hgetall: hgetallMock + }) + + const { getCachedTotalsWithTimestamp } = await import('./cache') + const result = await getCachedTotalsWithTimestamp( + '0x0000000000000000000000000000000000000001', + 'all', + '2025-01-02', + '2025-01-03' + ) + + expect(result.totals).toEqual([ + { date: '2025-01-02', usdValue: 2 }, + { date: '2025-01-03', usdValue: 3 } + ]) + expect(result.oldestUpdatedAt?.getTime()).toBe(2000) + }) +}) diff --git a/api/lib/holdings/services/cache.ts b/api/lib/holdings/services/cache.ts new file mode 100644 index 000000000..867746838 --- /dev/null +++ b/api/lib/holdings/services/cache.ts @@ -0,0 +1,354 @@ +import { createHash } from 'node:crypto' +import { getHoldingsRedisClient, handleHoldingsRedisError, isHoldingsStorageEnabled } from '../storage/redis' +import { debugError, debugLog } from './debug' + +export interface CachedTotal { + date: string + usdValue: number +} + +interface CachedTotalPayload { + usdValue: number + updatedAt: number +} + +interface ParsedCachedTotal { + date: string + usdValue: number + updatedAt: Date +} + +export interface CachedTotalsResult { + totals: CachedTotal[] + oldestUpdatedAt: Date | null +} + +export interface VaultIdentifier { + address: string + chainId: number +} + +const HOLDINGS_TOTALS_TTL_SECONDS = 30 * 24 * 60 * 60 +const HOLDINGS_TOTALS_KEY_PREFIX = 'holdings:totals' +const VAULT_INVALIDATION_KEY_PREFIX = 'holdings:vault-invalidated' +const REDIS_SCAN_COUNT = 500 + +function normalizeUserAddress(userAddress: string): string { + return userAddress.toLowerCase() +} + +function getUserAddressCacheKey(userAddress: string): string { + return createHash('sha256').update(normalizeUserAddress(userAddress)).digest('hex') +} + +function getTotalsKey(userAddressHash: string, version: string): string { + return `${HOLDINGS_TOTALS_KEY_PREFIX}:${userAddressHash}:${version}` +} + +function getTotalsKeyPattern(userAddressHash: string): string { + return `${HOLDINGS_TOTALS_KEY_PREFIX}:${userAddressHash}:*` +} + +function getVaultInvalidationKey(vault: VaultIdentifier): string { + return `${VAULT_INVALIDATION_KEY_PREFIX}:${vault.chainId}:${vault.address.toLowerCase()}` +} + +function parseJsonValue(value: unknown): unknown { + if (typeof value !== 'string') { + return value + } + + try { + return JSON.parse(value) + } catch { + return null + } +} + +function parseCachedTotalPayload(value: unknown): CachedTotalPayload | null { + const parsed = parseJsonValue(value) + if (!parsed || typeof parsed !== 'object') { + return null + } + + const payload = parsed as Partial + const usdValue = Number(payload.usdValue) + const updatedAt = Number(payload.updatedAt) + + if (!Number.isFinite(usdValue) || !Number.isFinite(updatedAt)) { + return null + } + + return { usdValue, updatedAt } +} + +function isDateInRange(date: string, startDate: string, endDate: string): boolean { + return date >= startDate && date <= endDate +} + +function parseCachedTotalsByDate( + valuesByDate: Record | null, + startDate: string, + endDate: string +): ParsedCachedTotal[] { + return Object.entries(valuesByDate ?? {}) + .filter(([date]) => isDateInRange(date, startDate, endDate)) + .map(([date, value]) => { + const payload = parseCachedTotalPayload(value) + return payload + ? { + date, + usdValue: payload.usdValue, + updatedAt: new Date(payload.updatedAt) + } + : null + }) + .filter((total): total is ParsedCachedTotal => total !== null && Number.isFinite(total.updatedAt.getTime())) + .sort((left, right) => left.date.localeCompare(right.date)) +} + +async function scanRedisKeys(pattern: string, cursor = '0', collectedKeys: string[] = []): Promise { + const redis = getHoldingsRedisClient() + if (!redis) { + return collectedKeys + } + + const [nextCursor, keys] = await redis.scan(cursor, { match: pattern, count: REDIS_SCAN_COUNT }) + const nextKeys = [...collectedKeys, ...keys] + return nextCursor === '0' ? nextKeys : scanRedisKeys(pattern, nextCursor, nextKeys) +} + +export async function getCachedTotals( + userAddress: string, + version: string, + startDate: string, + endDate: string +): Promise { + const result = await getCachedTotalsWithTimestamp(userAddress, version, startDate, endDate) + return result.totals +} + +export async function saveCachedTotals(userAddress: string, version: string, totals: CachedTotal[]): Promise { + const userAddressHash = getUserAddressCacheKey(userAddress) + + if (!isHoldingsStorageEnabled() || totals.length === 0) { + if (totals.length > 0) { + debugLog('cache', 'skipping cached totals save because Redis storage is disabled', { rows: totals.length }) + } + return false + } + + const redis = getHoldingsRedisClient() + if (!redis) { + debugLog('cache', 'skipping cached totals save because Redis client is unavailable', { rows: totals.length }) + return false + } + + try { + const updatedAt = Date.now() + const key = getTotalsKey(userAddressHash, version) + const valuesByDate = Object.fromEntries( + totals.map((total) => [ + total.date, + JSON.stringify({ + usdValue: total.usdValue, + updatedAt + } satisfies CachedTotalPayload) + ]) + ) + + debugLog('cache', 'saving cached totals to Redis', { + userAddressHash, + version, + rows: totals.length + }) + + await redis.hset(key, valuesByDate) + await redis.expire(key, HOLDINGS_TOTALS_TTL_SECONDS) + debugLog('cache', 'saved cached totals to Redis', { rows: totals.length }) + return true + } catch (error) { + handleHoldingsRedisError('cached totals save failed', error) + debugError('cache', 'cached totals save failed', error, { rows: totals.length }) + return false + } +} + +export async function clearUserCache(userAddress: string, version?: string): Promise { + const userAddressHash = getUserAddressCacheKey(userAddress) + + if (!isHoldingsStorageEnabled()) { + debugLog('cache', 'skipping user cache clear because Redis storage is disabled', { + userAddressHash, + version: version ?? null + }) + return 0 + } + + const redis = getHoldingsRedisClient() + if (!redis) { + debugLog('cache', 'skipping user cache clear because Redis client is unavailable', { + userAddressHash, + version: version ?? null + }) + return 0 + } + + try { + const keys = version + ? [getTotalsKey(userAddressHash, version)] + : await scanRedisKeys(getTotalsKeyPattern(userAddressHash)) + const deletedCount = keys.length > 0 ? await redis.del(...keys) : 0 + console.log( + `[Cache] Cleared ${deletedCount} Redis cached entries for user ${userAddress}${version ? ` (${version})` : ''}` + ) + return deletedCount + } catch (error) { + handleHoldingsRedisError('user cache clear failed', error) + debugError('cache', 'user cache clear failed', error, { + userAddressHash, + version: version ?? null + }) + return 0 + } +} + +export async function invalidateVaults(vaults: VaultIdentifier[]): Promise { + if (!isHoldingsStorageEnabled() || vaults.length === 0) { + if (vaults.length > 0) { + debugLog('cache', 'skipping vault invalidation because Redis storage is disabled', { vaults: vaults.length }) + } + return 0 + } + + const redis = getHoldingsRedisClient() + if (!redis) { + debugLog('cache', 'skipping vault invalidation because Redis client is unavailable', { vaults: vaults.length }) + return 0 + } + + try { + const invalidatedAt = String(Date.now()) + const valuesByKey = Object.fromEntries(vaults.map((vault) => [getVaultInvalidationKey(vault), invalidatedAt])) + await redis.mset(valuesByKey) + console.log(`[Cache] Invalidated ${vaults.length} vaults in Redis`) + return vaults.length + } catch (error) { + handleHoldingsRedisError('vault invalidation failed', error) + debugError('cache', 'vault invalidation failed', error, { vaults: vaults.length }) + return 0 + } +} + +export async function checkCacheStaleness( + vaults: VaultIdentifier[], + cacheOldestTimestamp: Date | null +): Promise { + if (!isHoldingsStorageEnabled() || vaults.length === 0 || !cacheOldestTimestamp) { + if (vaults.length > 0 && cacheOldestTimestamp !== null) { + debugLog('cache', 'skipping cache staleness check because Redis storage is disabled', { vaults: vaults.length }) + } + return false + } + + const redis = getHoldingsRedisClient() + if (!redis) { + debugLog('cache', 'skipping cache staleness check because Redis client is unavailable', { vaults: vaults.length }) + return false + } + + try { + debugLog('cache', 'checking Redis cache staleness', { + vaults: vaults.length, + cacheOldestTimestamp: cacheOldestTimestamp.toISOString() + }) + const keys = vaults.map(getVaultInvalidationKey) + const invalidationValues = await redis.mget>(keys) + const latestInvalidationMs = invalidationValues + .map((value) => Number(value)) + .filter((value) => Number.isFinite(value)) + .reduce((latest, value) => (latest === null || value > latest ? value : latest), null) + + if (latestInvalidationMs === null) { + return false + } + + const latestInvalidation = new Date(latestInvalidationMs) + const isStale = latestInvalidation > cacheOldestTimestamp + debugLog('cache', 'checked Redis cache staleness', { + vaults: vaults.length, + latestInvalidation: latestInvalidation.toISOString(), + cacheOldestTimestamp: cacheOldestTimestamp.toISOString(), + isStale + }) + + if (isStale) { + console.log( + `[Cache] Cache is stale: invalidation at ${latestInvalidation.toISOString()} > cache at ${cacheOldestTimestamp.toISOString()}` + ) + } + + return isStale + } catch (error) { + handleHoldingsRedisError('cache staleness check failed', error) + debugError('cache', 'cache staleness check failed', error, { vaults: vaults.length }) + return false + } +} + +export async function getCachedTotalsWithTimestamp( + userAddress: string, + version: string, + startDate: string, + endDate: string +): Promise { + const userAddressHash = getUserAddressCacheKey(userAddress) + + if (!isHoldingsStorageEnabled()) { + debugLog('cache', 'skipping cached totals with timestamp lookup because Redis storage is disabled') + return { totals: [], oldestUpdatedAt: null } + } + + const redis = getHoldingsRedisClient() + if (!redis) { + debugLog('cache', 'skipping cached totals with timestamp lookup because Redis client is unavailable') + return { totals: [], oldestUpdatedAt: null } + } + + try { + debugLog('cache', 'loading cached totals with timestamps from Redis', { + userAddressHash, + version, + startDate, + endDate + }) + const valuesByDate = await redis.hgetall>(getTotalsKey(userAddressHash, version)) + const parsedTotals = parseCachedTotalsByDate(valuesByDate, startDate, endDate) + const totals = parsedTotals.map((total) => ({ + date: total.date, + usdValue: total.usdValue + })) + const oldestUpdatedAt = + parsedTotals.length > 0 + ? parsedTotals.reduce( + (oldest, total) => (total.updatedAt < oldest ? total.updatedAt : oldest), + parsedTotals[0].updatedAt + ) + : null + + debugLog('cache', 'loaded cached totals with timestamps from Redis', { + rows: totals.length, + oldestUpdatedAt: oldestUpdatedAt?.toISOString() ?? null + }) + return { totals, oldestUpdatedAt } + } catch (error) { + handleHoldingsRedisError('cached totals with timestamp lookup failed', error) + debugError('cache', 'cached totals with timestamp lookup failed', error, { + userAddressHash, + version, + startDate, + endDate + }) + return { totals: [], oldestUpdatedAt: null } + } +} diff --git a/api/lib/holdings/services/debug.ts b/api/lib/holdings/services/debug.ts new file mode 100644 index 000000000..54cc747f6 --- /dev/null +++ b/api/lib/holdings/services/debug.ts @@ -0,0 +1,127 @@ +import { AsyncLocalStorage } from 'node:async_hooks' +import { appendHoldingsProgressLog, updateHoldingsProgress } from './progress' + +export interface HoldingsDebugContext { + enabled: boolean + requestId: string + route: 'history' | 'breakdown' | 'protocol-return-history' + address: string + startedAt: number + lotsEnabled: boolean + vaultFilter: string | null + txFilter: string | null + progressId: string | null +} + +const storage = new AsyncLocalStorage() + +function formatPayload(payload?: Record): string { + if (!payload || Object.keys(payload).length === 0) { + return '' + } + + return ` ${JSON.stringify(payload)}` +} + +export function isHoldingsDebugRequested(debugValue?: string | null): boolean { + if (!debugValue) { + return false + } + + const normalized = debugValue.toLowerCase() + return normalized === '1' || normalized === 'true' || normalized === 'yes' || normalized === 'on' +} + +export function createHoldingsDebugContext( + route: 'history' | 'breakdown' | 'protocol-return-history', + address: string, + enabled: boolean, + options?: { + lotsEnabled?: boolean + vaultFilter?: string | null + txFilter?: string | null + progressId?: string | null + } +): HoldingsDebugContext { + return { + enabled, + requestId: `${route}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`, + route, + address: address.toLowerCase(), + startedAt: Date.now(), + lotsEnabled: options?.lotsEnabled ?? false, + vaultFilter: options?.vaultFilter?.toLowerCase() ?? null, + txFilter: options?.txFilter?.toLowerCase() ?? null, + progressId: options?.progressId ?? null + } +} + +export async function withHoldingsDebugContext(context: HoldingsDebugContext, fn: () => Promise): Promise { + return storage.run(context, fn) +} + +export function getHoldingsDebugContext(): HoldingsDebugContext | undefined { + return storage.getStore() +} + +export function getHoldingsDebugFilters(): { + lotsEnabled: boolean + vaultFilter: string | null + txFilter: string | null +} { + const context = getHoldingsDebugContext() + + return { + lotsEnabled: context?.lotsEnabled ?? false, + vaultFilter: context?.vaultFilter ?? null, + txFilter: context?.txFilter ?? null + } +} + +export function debugLog(scope: string, message: string, payload?: Record): void { + const context = getHoldingsDebugContext() + + if (!context) { + return + } + + const elapsedMs = Date.now() - context.startedAt + void appendHoldingsProgressLog(context.progressId, { elapsedMs, scope, message }) + + if (!context.enabled) { + return + } + + console.log(`[HoldingsDebug][${context.requestId}][+${elapsedMs}ms][${scope}] ${message}${formatPayload(payload)}`) +} + +export function reportHoldingsProgress(progress: number, message: string, detail?: string | null): void { + const context = getHoldingsDebugContext() + void updateHoldingsProgress(context?.progressId, { progress, message, detail: detail ?? null }) +} + +export function debugError(scope: string, message: string, error: unknown, payload?: Record): void { + const context = getHoldingsDebugContext() + + if (!context?.enabled) { + return + } + + const errorMessage = error instanceof Error ? error.message : String(error) + debugLog(scope, message, { + ...payload, + error: errorMessage + }) +} + +export function debugTable(scope: string, message: string, rows: Array>): void { + const context = getHoldingsDebugContext() + + if (!context?.enabled) { + return + } + + const elapsedMs = Date.now() - context.startedAt + console.log(`[HoldingsDebug][${context.requestId}][+${elapsedMs}ms][${scope}] ${message}`) + console.table(rows) +} diff --git a/api/lib/holdings/services/defillama.test.ts b/api/lib/holdings/services/defillama.test.ts new file mode 100644 index 000000000..228586643 --- /dev/null +++ b/api/lib/holdings/services/defillama.test.ts @@ -0,0 +1,970 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { DefiLlamaBatchResponse } from '../types' +import { + fetchHistoricalPrices, + fetchHistoricalPricesForTokenTimestamps, + getChainPrefix, + getHistoricalPriceFetchFailedBatches, + getPriceAtTimestamp, + parseDefiLlamaResponse +} from './defillama' + +function createBatchResponse(response: DefiLlamaBatchResponse): Response { + return new Response(JSON.stringify(response), { + status: 200, + headers: { 'content-type': 'application/json' } + }) +} + +afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllGlobals() + vi.unstubAllEnvs() +}) + +describe('parseDefiLlamaResponse', () => { + it('maps Katana chain IDs to the katana DefiLlama prefix', () => { + expect(getChainPrefix(747474)).toBe('katana') + }) + + it('uses the katana chain prefix for Katana token requests', async () => { + const katanaToken = '0xee7d8bcfb72bc1880d0cf19822eb0a2e6577ab62' + const fetchStub = vi.fn().mockResolvedValue( + createBatchResponse({ + coins: { + [`katana:${katanaToken}`]: { + symbol: 'vbETH', + prices: [{ timestamp: 1700000000, price: 2000, confidence: 0.99 }] + } + } + }) + ) + + vi.stubGlobal('fetch', fetchStub) + + const prices = await fetchHistoricalPrices([{ chainId: 747474, address: katanaToken }], [1700000000]) + + expect(fetchStub).toHaveBeenCalledTimes(1) + + const requestUrl = new URL(fetchStub.mock.calls[0][0] as string) + const coinsParam = JSON.parse(decodeURIComponent(requestUrl.searchParams.get('coins') ?? 'null')) as Record< + string, + number[] + > + + expect(coinsParam).toEqual({ + [`katana:${katanaToken}`]: [1700000000] + }) + expect(prices.get(`katana:${katanaToken}`)?.get(1700000000)).toBe(2000) + }) + + it('uses returned timestamps instead of assuming the requested order is preserved', () => { + const response: DefiLlamaBatchResponse = { + coins: { + 'ethereum:0xf939e0a03fb07f59a73314e73794be0e57ac1b4e': { + symbol: 'crvUSD', + prices: [ + { timestamp: 1773260095, price: 0.9966185770862551, confidence: 0.99 }, + { timestamp: 1700000102, price: 0.999211, confidence: 0.99 } + ] + } + } + } + + const parsed = parseDefiLlamaResponse(response, [1700000000, 1773260546]) + const priceMap = parsed.get('ethereum:0xf939e0a03fb07f59a73314e73794be0e57ac1b4e') + + expect(priceMap?.get(1773260095)).toBe(0.9966185770862551) + expect(priceMap?.get(1700000102)).toBe(0.999211) + expect(getPriceAtTimestamp(priceMap ?? new Map(), 1773260546)).toBe(0.9966185770862551) + }) + + it('uses yearn-prices when selected and maps UTC day-end prices back to requested timestamps', async () => { + vi.stubEnv('HOLDINGS_PRICE_PROVIDER', 'yearn-prices') + vi.stubEnv('YEARN_PRICES_BASE_URL', 'https://prices.example') + vi.stubEnv('YEARN_PRICES_API_KEY', 'test-yearn-prices-key') + + const tokenAddress = '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48' + const tokenKey = `ethereum:${tokenAddress}` + const requestedTimestamp = 1700000000 + const normalizedTimestamp = 1700006399 + const fetchStub = vi.fn().mockResolvedValue( + createBatchResponse({ + coins: { + [tokenKey]: { + symbol: 'USDC', + prices: [{ timestamp: normalizedTimestamp, price: 1.002, confidence: 0.99 }] + } + } + }) + ) + + vi.stubGlobal('fetch', fetchStub) + + const prices = await fetchHistoricalPricesForTokenTimestamps([ + { + chainId: 1, + address: tokenAddress, + timestamps: [requestedTimestamp] + } + ]) + + expect(fetchStub).toHaveBeenCalledTimes(1) + + const [requestInput, requestInit] = fetchStub.mock.calls[0] ?? [] + const requestUrl = new URL(String(requestInput)) + const coinsParam = JSON.parse(decodeURIComponent(requestUrl.searchParams.get('coins') ?? 'null')) as Record< + string, + number[] + > + + expect(requestUrl.origin).toBe('https://prices.example') + expect(requestUrl.pathname).toBe('/api/prices/batchHistorical') + expect(coinsParam).toEqual({ + [tokenKey]: [normalizedTimestamp] + }) + expect(requestInit).toEqual({ + headers: { + Authorization: 'Bearer test-yearn-prices-key' + }, + signal: expect.any(AbortSignal) + }) + expect(prices.get(tokenKey)?.get(requestedTimestamp)).toBe(1.002) + }) + + it('uses API_KEY_PORTFOLIO as the default yearn-prices bearer token', async () => { + vi.stubEnv('API_KEY_PORTFOLIO', 'portfolio-test-key') + + const tokenAddress = '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48' + const tokenKey = `ethereum:${tokenAddress}` + const requestedTimestamp = 1700000000 + const normalizedTimestamp = 1700006399 + const fetchStub = vi.fn().mockResolvedValue( + createBatchResponse({ + coins: { + [tokenKey]: { + symbol: 'USDC', + prices: [{ timestamp: normalizedTimestamp, price: 1.002, confidence: 0.99 }] + } + } + }) + ) + + vi.stubGlobal('fetch', fetchStub) + + await fetchHistoricalPricesForTokenTimestamps([ + { + chainId: 1, + address: tokenAddress, + timestamps: [requestedTimestamp] + } + ]) + + const [requestInput, requestInit] = fetchStub.mock.calls[0] ?? [] + const requestUrl = new URL(String(requestInput)) + + expect(requestUrl.origin).toBe('https://prices.yearn.dev') + expect(requestInit).toEqual({ + headers: { + Authorization: 'Bearer portfolio-test-key' + }, + signal: expect.any(AbortSignal) + }) + }) + + it('uses yearn-prices range requests for contiguous daily timestamp history', async () => { + vi.stubEnv('API_KEY_PORTFOLIO', 'portfolio-test-key') + + const firstTokenAddress = '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48' + const secondTokenAddress = '0xc2d3d421e23149b78d1843d0d59530dc0bd5add4' + const firstTokenKey = `ethereum:${firstTokenAddress}` + const secondTokenKey = `ethereum:${secondTokenAddress}` + const firstTimestamp = 1704153599 + const secondTimestamp = 1704239999 + const fetchStub = vi.fn().mockResolvedValue( + createBatchResponse({ + coins: { + [firstTokenKey]: { + symbol: 'USDC', + prices: [ + { timestamp: firstTimestamp, price: 1.001, confidence: 0.99 }, + { timestamp: secondTimestamp, price: 1.002, confidence: 0.99 } + ] + }, + [secondTokenKey]: { + symbol: 'TKN', + prices: [ + { timestamp: firstTimestamp, price: 2.001, confidence: 0.99 }, + { timestamp: secondTimestamp, price: 2.002, confidence: 0.99 } + ] + } + } + }) + ) + + vi.stubGlobal('fetch', fetchStub) + + const prices = await fetchHistoricalPricesForTokenTimestamps([ + { + chainId: 1, + address: firstTokenAddress, + timestamps: [firstTimestamp, secondTimestamp] + }, + { + chainId: 1, + address: secondTokenAddress, + timestamps: [firstTimestamp, secondTimestamp] + } + ]) + + expect(fetchStub).toHaveBeenCalledTimes(1) + + const [requestInput, requestInit] = fetchStub.mock.calls[0] ?? [] + const requestUrl = new URL(String(requestInput)) + const coinsParam = JSON.parse(decodeURIComponent(requestUrl.searchParams.get('coins') ?? 'null')) as Record< + string, + [number, number] + > + + expect(requestUrl.origin).toBe('https://prices.yearn.dev') + expect(requestUrl.pathname).toBe('/api/prices/rangeHistorical') + expect(coinsParam).toEqual({ + [firstTokenKey]: [firstTimestamp, secondTimestamp], + [secondTokenKey]: [firstTimestamp, secondTimestamp] + }) + expect(requestInit).toEqual({ + headers: { + Authorization: 'Bearer portfolio-test-key' + }, + signal: expect.any(AbortSignal) + }) + expect(prices.get(firstTokenKey)?.get(firstTimestamp)).toBe(1.001) + expect(prices.get(firstTokenKey)?.get(secondTimestamp)).toBe(1.002) + expect(prices.get(secondTokenKey)?.get(firstTimestamp)).toBe(2.001) + expect(prices.get(secondTokenKey)?.get(secondTimestamp)).toBe(2.002) + }) + + it('keeps yearn-prices batchHistorical requests below the conservative pair cap', async () => { + vi.stubEnv('API_KEY_PORTFOLIO', 'portfolio-test-key') + + const tokens = Array.from({ length: 4 }, (_value, index) => ({ + chainId: 1, + address: `0x${(index + 1).toString(16).padStart(40, '0')}` + })) + const timestamps = Array.from({ length: 50 }, (_value, index) => 1704153599 + index * 2 * 86_400) + const fetchStub = vi.fn(async (input: string | URL | Request) => { + const requestUrl = new URL(input.toString()) + const coinsParam = JSON.parse(decodeURIComponent(requestUrl.searchParams.get('coins') ?? 'null')) as Record< + string, + number[] + > + + return createBatchResponse({ + coins: Object.fromEntries( + Object.entries(coinsParam).map(([coinKey, requestedTimestamps]) => [ + coinKey, + { + symbol: 'TKN', + prices: requestedTimestamps.map((requestedTimestamp) => ({ + timestamp: requestedTimestamp, + price: 1, + confidence: 0.99 + })) + } + ]) + ) + }) + }) + + vi.stubGlobal('fetch', fetchStub) + + const prices = await fetchHistoricalPricesForTokenTimestamps( + tokens.map((token) => ({ + ...token, + timestamps + })) + ) + + expect(fetchStub.mock.calls.length).toBeGreaterThan(1) + fetchStub.mock.calls.forEach(([requestInput]) => { + const requestUrl = new URL(String(requestInput)) + const coinsParam = JSON.parse(decodeURIComponent(requestUrl.searchParams.get('coins') ?? 'null')) as Record< + string, + number[] + > + const pairCount = Object.values(coinsParam).reduce((total, requestedTimestamps) => { + return total + requestedTimestamps.length + }, 0) + + expect(requestUrl.pathname).toBe('/api/prices/batchHistorical') + expect(pairCount).toBeLessThanOrEqual(150) + Object.values(coinsParam).forEach((requestedTimestamps) => { + expect(requestedTimestamps.length).toBeLessThanOrEqual(45) + }) + }) + expect(prices.size).toBe(tokens.length) + }) + + it('splits yearn-prices batches after timeout errors', async () => { + vi.stubEnv('API_KEY_PORTFOLIO', 'portfolio-test-key') + + const firstTokenAddress = '0x0000000000000000000000000000000000000001' + const secondTokenAddress = '0x0000000000000000000000000000000000000002' + const timestamp = 1704153599 + const fetchStub = vi.fn(async (input: string | URL | Request) => { + const requestUrl = new URL(input.toString()) + const coinsParam = JSON.parse(decodeURIComponent(requestUrl.searchParams.get('coins') ?? 'null')) as Record< + string, + number[] + > + + if (Object.keys(coinsParam).length > 1) { + const error = new Error('The operation timed out.') + error.name = 'TimeoutError' + throw error + } + + return createBatchResponse({ + coins: Object.fromEntries( + Object.entries(coinsParam).map(([coinKey, requestedTimestamps]) => [ + coinKey, + { + symbol: 'TKN', + prices: requestedTimestamps.map((requestedTimestamp) => ({ + timestamp: requestedTimestamp, + price: 1, + confidence: 0.99 + })) + } + ]) + ) + }) + }) + + vi.stubGlobal('fetch', fetchStub) + + const prices = await fetchHistoricalPricesForTokenTimestamps([ + { chainId: 1, address: firstTokenAddress, timestamps: [timestamp] }, + { chainId: 1, address: secondTokenAddress, timestamps: [timestamp] } + ]) + + expect(fetchStub).toHaveBeenCalledTimes(3) + expect(prices.get(`ethereum:${firstTokenAddress}`)?.get(timestamp)).toBe(1) + expect(prices.get(`ethereum:${secondTokenAddress}`)?.get(timestamp)).toBe(1) + }) + + it('only uses historical prices at or before the requested timestamp', () => { + const priceMap = new Map([ + [1700000102, 0.999211], + [1773260095, 0.9966185770862551] + ]) + + expect(getPriceAtTimestamp(priceMap, 1700000101)).toBe(0) + expect(getPriceAtTimestamp(priceMap, 1700000102)).toBe(0.999211) + expect(getPriceAtTimestamp(priceMap, 1773260546)).toBe(0.9966185770862551) + }) + + it('retries bun connection refused errors and returns fetched prices', async () => { + const fetchStub = vi + .fn() + .mockRejectedValueOnce(Object.assign(new Error('Unable to connect'), { code: 'ConnectionRefused' })) + .mockResolvedValue( + createBatchResponse({ + coins: { + 'ethereum:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48': { + symbol: 'USDC', + prices: [{ timestamp: 1700000000, price: 1, confidence: 0.99 }] + } + } + }) + ) + + vi.stubGlobal('fetch', fetchStub) + + const prices = await fetchHistoricalPrices( + [{ chainId: 1, address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' }], + [1700000000] + ) + + expect(fetchStub).toHaveBeenCalledTimes(2) + expect(prices.get('ethereum:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48')?.get(1700000000)).toBe(1) + }) + + it('throws when every DefiLlama batch request fails', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + const fetchStub = vi + .fn() + .mockRejectedValue(Object.assign(new Error('Unable to connect'), { code: 'ConnectionRefused' })) + + vi.stubGlobal('fetch', fetchStub) + + await expect( + fetchHistoricalPrices([{ chainId: 1, address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' }], [1700000000]) + ).rejects.toThrow('Failed to fetch token prices from DefiLlama') + }) + + it('marks partial price fetch failures so callers can avoid caching derived totals', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + const tokens = Array.from({ length: 51 }, (_value, index) => ({ + chainId: 1, + address: `0x${(index + 1).toString(16).padStart(40, '0')}` + })) + const successfulTokenKey = 'ethereum:0x0000000000000000000000000000000000000033' + const fetchStub = vi + .fn() + .mockResolvedValueOnce(new Response(null, { status: 400 })) + .mockResolvedValueOnce( + createBatchResponse({ + coins: { + [successfulTokenKey]: { + symbol: 'TKN', + prices: [{ timestamp: 1700000000, price: 1, confidence: 0.99 }] + } + } + }) + ) + + vi.stubGlobal('fetch', fetchStub) + + const prices = await fetchHistoricalPrices(tokens, [1700000000]) + + expect(getHistoricalPriceFetchFailedBatches(prices)).toBe(1) + expect(prices.get(successfulTokenKey)?.get(1700000000)).toBe(1) + }) + + it('fetches requested token-timestamp pairs directly without local price cache filtering', async () => { + const usdcKey = 'ethereum:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48' + const daiKey = 'ethereum:0x6b175474e89094c44da98b954eedeac495271d0f' + + const fetchStub = vi.fn().mockResolvedValue( + createBatchResponse({ + coins: { + [usdcKey]: { + symbol: 'USDC', + prices: [ + { timestamp: 1700000000, price: 1, confidence: 0.99 }, + { timestamp: 1700003600, price: 1.001, confidence: 0.99 } + ] + }, + [daiKey]: { + symbol: 'DAI', + prices: [ + { timestamp: 1700000000, price: 0.999, confidence: 0.99 }, + { timestamp: 1700003600, price: 1, confidence: 0.99 } + ] + } + } + }) + ) + + vi.stubGlobal('fetch', fetchStub) + + const prices = await fetchHistoricalPrices( + [ + { chainId: 1, address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' }, + { chainId: 1, address: '0x6B175474E89094C44Da98b954EedeAC495271d0F' } + ], + [1700000000, 1700003600] + ) + + expect(fetchStub).toHaveBeenCalledTimes(1) + + const requestUrl = new URL(fetchStub.mock.calls[0][0] as string) + const coinsParam = JSON.parse(decodeURIComponent(requestUrl.searchParams.get('coins') ?? 'null')) as Record< + string, + number[] + > + + expect(coinsParam).toEqual({ + [usdcKey]: [1700000000, 1700003600], + [daiKey]: [1700000000, 1700003600] + }) + expect(prices.get(usdcKey)?.get(1700000000)).toBe(1) + expect(prices.get(usdcKey)?.get(1700003600)).toBe(1.001) + expect(prices.get(daiKey)?.get(1700000000)).toBe(0.999) + expect(prices.get(daiKey)?.get(1700003600)).toBe(1) + }) + + it('merges multiple timestamp slices for the same token into a single batch request', async () => { + const usdcKey = 'ethereum:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48' + const timestamps = [ + 1700000000, 1700000600, 1700001200, 1700001800, 1700002400, 1700003000, 1700003600, 1700004200, 1700004800, + 1700005400, 1700006000, 1700006600 + ] + const fetchStub = vi.fn().mockResolvedValue( + createBatchResponse({ + coins: { + [usdcKey]: { + symbol: 'USDC', + prices: timestamps.map((timestamp, index) => ({ + timestamp, + price: 1 + index / 1000, + confidence: 0.99 + })) + } + } + }) + ) + + vi.stubGlobal('fetch', fetchStub) + + const prices = await fetchHistoricalPrices( + [{ chainId: 1, address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' }], + timestamps + ) + + expect(fetchStub).toHaveBeenCalledTimes(1) + + const requestUrl = new URL(fetchStub.mock.calls[0][0] as string) + const coinsParam = JSON.parse(decodeURIComponent(requestUrl.searchParams.get('coins') ?? 'null')) as Record< + string, + number[] + > + + expect(coinsParam).toEqual({ + [usdcKey]: timestamps + }) + timestamps.forEach((timestamp, index) => { + expect(prices.get(usdcKey)?.get(timestamp)).toBe(1 + index / 1000) + }) + }) + + it('batches up to 50 token addresses into a single request', async () => { + const tokens = Array.from({ length: 51 }, (_value, index) => ({ + chainId: 1, + address: `0x${(index + 1).toString(16).padStart(40, '0')}` + })) + const timestamp = 1700000000 + const fetchStub = vi.fn(async (input: string | URL | Request) => { + const requestUrl = new URL(input.toString()) + const coinsParam = JSON.parse(decodeURIComponent(requestUrl.searchParams.get('coins') ?? 'null')) as Record< + string, + number[] + > + + return createBatchResponse({ + coins: Object.fromEntries( + Object.entries(coinsParam).map(([coinKey, requestedTimestamps]) => [ + coinKey, + { + symbol: 'TKN', + prices: requestedTimestamps.map((requestedTimestamp) => ({ + timestamp: requestedTimestamp, + price: 1, + confidence: 0.99 + })) + } + ]) + ) + }) + }) + + vi.stubGlobal('fetch', fetchStub) + + const prices = await fetchHistoricalPrices(tokens, [timestamp]) + + expect(fetchStub).toHaveBeenCalledTimes(2) + + const firstRequestUrl = new URL(fetchStub.mock.calls[0][0] as string) + const secondRequestUrl = new URL(fetchStub.mock.calls[1][0] as string) + const firstCoinsParam = JSON.parse( + decodeURIComponent(firstRequestUrl.searchParams.get('coins') ?? 'null') + ) as Record + const secondCoinsParam = JSON.parse( + decodeURIComponent(secondRequestUrl.searchParams.get('coins') ?? 'null') + ) as Record + + expect(Object.keys(firstCoinsParam)).toHaveLength(50) + expect(Object.keys(secondCoinsParam)).toHaveLength(1) + expect(prices.size).toBe(51) + }) + + it('uses the paid DefiLlama pro API GET route with larger batches when DEFILLAMA_API_KEY is set', async () => { + vi.stubEnv('DEFILLAMA_API_KEY', 'test-llama-key') + + const tokens = Array.from({ length: 90 }, (_value, index) => ({ + chainId: 1, + address: `0x${(index + 1).toString(16).padStart(40, '0')}` + })) + const timestamp = 1700000000 + const fetchStub = vi.fn(async (input: string | URL | Request) => { + const requestUrl = new URL(input.toString()) + const coinsParam = JSON.parse(decodeURIComponent(requestUrl.searchParams.get('coins') ?? 'null')) as Record< + string, + number[] + > + + return createBatchResponse({ + coins: Object.fromEntries( + Object.entries(coinsParam).map(([coinKey, requestedTimestamps]) => [ + coinKey, + { + symbol: 'TKN', + prices: requestedTimestamps.map((requestedTimestamp) => ({ + timestamp: requestedTimestamp, + price: 1, + confidence: 0.99 + })) + } + ]) + ) + }) + }) + + vi.stubGlobal('fetch', fetchStub) + + const prices = await fetchHistoricalPrices(tokens, [timestamp]) + + expect(fetchStub.mock.calls.length).toBeGreaterThan(1) + fetchStub.mock.calls.forEach(([requestInput, requestInit]) => { + const requestUrl = new URL(String(requestInput)) + const requestCoinsParam = JSON.parse( + decodeURIComponent(requestUrl.searchParams.get('coins') ?? 'null') + ) as Record + + expect(requestUrl.origin).toBe('https://pro-api.llama.fi') + expect(requestUrl.pathname).toBe('/test-llama-key/coins/batchHistorical') + expect(requestUrl.toString().length).toBeLessThanOrEqual(3_500) + expect(requestInit).toEqual({ signal: expect.any(AbortSignal) }) + expect(Object.keys(requestCoinsParam).length).toBeGreaterThan(0) + }) + expect(prices.size).toBe(90) + }) + + it('falls back to the free GET route when the paid GET route fails', async () => { + vi.stubEnv('DEFILLAMA_API_KEY', 'test-llama-key') + + const fetchStub = vi + .fn() + .mockResolvedValueOnce({ + ok: false, + status: 404 + } satisfies Partial) + .mockResolvedValueOnce( + createBatchResponse({ + coins: { + 'ethereum:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48': { + symbol: 'USDC', + prices: [{ timestamp: 1700000000, price: 1, confidence: 0.99 }] + } + } + }) + ) + + vi.stubGlobal('fetch', fetchStub) + + const prices = await fetchHistoricalPrices( + [{ chainId: 1, address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48' }], + [1700000000] + ) + + expect(fetchStub).toHaveBeenCalledTimes(2) + + const [firstRequestInput, firstRequestInit] = fetchStub.mock.calls[0] ?? [] + const [secondRequestInput, secondRequestInit] = fetchStub.mock.calls[1] ?? [] + const firstRequestUrl = new URL(String(firstRequestInput)) + const firstCoinsParam = JSON.parse( + decodeURIComponent(firstRequestUrl.searchParams.get('coins') ?? 'null') + ) as Record + + expect(firstRequestUrl.origin).toBe('https://pro-api.llama.fi') + expect(firstRequestUrl.pathname).toBe('/test-llama-key/coins/batchHistorical') + expect(firstRequestInit).toEqual({ signal: expect.any(AbortSignal) }) + expect(firstCoinsParam).toEqual({ + 'ethereum:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48': [1700000000] + }) + expect(String(secondRequestInput)).toBe( + 'https://coins.llama.fi/batchHistorical?coins=%7B%22ethereum%3A0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48%22%3A%5B1700000000%5D%7D' + ) + expect(secondRequestInit).toEqual({ signal: expect.any(AbortSignal) }) + expect(prices.get('ethereum:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48')?.get(1700000000)).toBe(1) + }) + + it('splits paid GET batches before the request URL grows beyond the configured limit', async () => { + vi.stubEnv('DEFILLAMA_API_KEY', 'test-llama-key') + + const tokens = Array.from({ length: 20 }, (_value, index) => ({ + chainId: 1, + address: `0x${(index + 1).toString(16).padStart(40, '0')}` + })) + const timestamps = Array.from({ length: 50 }, (_value, index) => 1700000000 + index * 60) + const fetchStub = vi.fn(async (input: string | URL | Request) => { + const requestUrl = new URL(input.toString()) + const coinsParam = JSON.parse(decodeURIComponent(requestUrl.searchParams.get('coins') ?? 'null')) as Record< + string, + number[] + > + + return createBatchResponse({ + coins: Object.fromEntries( + Object.entries(coinsParam).map(([coinKey, requestedTimestamps]) => [ + coinKey, + { + symbol: 'TKN', + prices: requestedTimestamps.map((requestedTimestamp) => ({ + timestamp: requestedTimestamp, + price: 1, + confidence: 0.99 + })) + } + ]) + ) + }) + }) + + vi.stubGlobal('fetch', fetchStub) + + const prices = await fetchHistoricalPrices(tokens, timestamps) + + expect(fetchStub.mock.calls.length).toBeGreaterThan(1) + fetchStub.mock.calls.forEach(([requestInput, requestInit]) => { + const requestUrl = new URL(String(requestInput)) + + expect(requestUrl.origin).toBe('https://pro-api.llama.fi') + expect(requestUrl.pathname).toBe('/test-llama-key/coins/batchHistorical') + expect(requestUrl.toString().length).toBeLessThanOrEqual(3_500) + expect(requestInit).toEqual({ signal: expect.any(AbortSignal) }) + }) + expect(prices.size).toBe(20) + }) + + it('recursively splits oversized GET batches when the server rejects them', async () => { + vi.stubEnv('DEFILLAMA_API_KEY', 'test-llama-key') + + const fetchStub = vi.fn(async (input: string | URL | Request) => { + const requestUrl = new URL(input.toString()) + const coinsParam = JSON.parse(decodeURIComponent(requestUrl.searchParams.get('coins') ?? 'null')) as Record< + string, + number[] + > + const requestedCoinCount = Object.keys(coinsParam).length + + if (requestedCoinCount > 1) { + return new Response(null, { status: 431 }) + } + + return createBatchResponse({ + coins: Object.fromEntries( + Object.entries(coinsParam).map(([coinKey, requestedTimestamps]) => [ + coinKey, + { + symbol: 'TKN', + prices: requestedTimestamps.map((requestedTimestamp) => ({ + timestamp: requestedTimestamp, + price: 1, + confidence: 0.99 + })) + } + ]) + ) + }) + }) + + vi.stubGlobal('fetch', fetchStub) + + const prices = await fetchHistoricalPrices( + [ + { chainId: 1, address: '0x0000000000000000000000000000000000000001' }, + { chainId: 1, address: '0x0000000000000000000000000000000000000002' } + ], + [1700000000] + ) + + expect(fetchStub.mock.calls.length).toBeGreaterThan(1) + expect(prices.get('ethereum:0x0000000000000000000000000000000000000001')?.get(1700000000)).toBe(1) + expect(prices.get('ethereum:0x0000000000000000000000000000000000000002')?.get(1700000000)).toBe(1) + }) + + it('interleaves token timestamp slices so multi-token requests stay grouped together', async () => { + const tokens = Array.from({ length: 6 }, (_value, index) => ({ + chainId: 1, + address: `0x${(index + 1).toString(16).padStart(40, '0')}` + })) + const timestamps = [ + 1700000000, 1700000600, 1700001200, 1700001800, 1700002400, 1700003000, 1700003600, 1700004200, 1700004800, + 1700005400, 1700006000, 1700006600 + ] + const fetchStub = vi.fn(async (input: string | URL | Request) => { + const requestUrl = new URL(input.toString()) + const coinsParam = JSON.parse(decodeURIComponent(requestUrl.searchParams.get('coins') ?? 'null')) as Record< + string, + number[] + > + + return createBatchResponse({ + coins: Object.fromEntries( + Object.entries(coinsParam).map(([coinKey, requestedTimestamps]) => [ + coinKey, + { + symbol: 'TKN', + prices: requestedTimestamps.map((requestedTimestamp) => ({ + timestamp: requestedTimestamp, + price: 1, + confidence: 0.99 + })) + } + ]) + ) + }) + }) + + vi.stubGlobal('fetch', fetchStub) + + await fetchHistoricalPrices(tokens, timestamps) + + expect(fetchStub).toHaveBeenCalledTimes(1) + + const requestUrl = new URL(fetchStub.mock.calls[0][0] as string) + const coinsParam = JSON.parse(decodeURIComponent(requestUrl.searchParams.get('coins') ?? 'null')) as Record< + string, + number[] + > + + expect(Object.keys(coinsParam)).toHaveLength(6) + Object.values(coinsParam).forEach((requestedTimestamps) => { + expect(requestedTimestamps).toEqual(timestamps) + }) + }) + + it('only returns shifted prices for requested timestamps that have a historical quote available', async () => { + const usdcKey = 'ethereum:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48' + const requestedTimestamps = [1700000000, 1700003600] + const fetchStub = vi.fn().mockResolvedValue( + createBatchResponse({ + coins: { + [usdcKey]: { + symbol: 'USDC', + prices: [ + { timestamp: 1700000102, price: 1.001, confidence: 0.99 }, + { timestamp: 1700003520, price: 0.999, confidence: 0.99 } + ] + } + } + }) + ) + + vi.stubGlobal('fetch', fetchStub) + + const prices = await fetchHistoricalPrices( + [{ chainId: 1, address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' }], + requestedTimestamps + ) + + expect(prices.get(usdcKey)?.get(1700000000)).toBeUndefined() + expect(prices.get(usdcKey)?.get(1700003600)).toBe(0.999) + }) + + it('returns an empty price map when DefiLlama returns no prices for a token', async () => { + const usdcKey = 'ethereum:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48' + const fetchStub = vi.fn().mockResolvedValue( + createBatchResponse({ + coins: {} + }) + ) + + vi.stubGlobal('fetch', fetchStub) + + const prices = await fetchHistoricalPrices( + [{ chainId: 1, address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' }], + [1700000000, 1700003600] + ) + + expect(prices.get(usdcKey)?.size).toBe(0) + }) + + it('fetches all requested timestamps without local price cache filtering', async () => { + const tokenAddress = '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48' + const tokenKey = `ethereum:${tokenAddress}` + const fetchStub = vi.fn().mockImplementation(() => + Promise.resolve( + createBatchResponse({ + coins: { + [tokenKey]: { + symbol: 'USDC', + prices: [ + { timestamp: 1700000000, price: 1, confidence: 0.99 }, + { timestamp: 1700003600, price: 1.001, confidence: 0.99 } + ] + } + } + }) + ) + ) + + vi.stubGlobal('fetch', fetchStub) + + const prices = await fetchHistoricalPricesForTokenTimestamps([ + { + chainId: 1, + address: tokenAddress, + timestamps: [1700000000, 1700003600] + } + ]) + + const requestUrl = new URL(fetchStub.mock.calls[0]?.[0] as string) + const coinsParam = JSON.parse(decodeURIComponent(requestUrl.searchParams.get('coins') ?? 'null')) as Record< + string, + number[] + > + + expect(coinsParam).toEqual({ + [tokenKey]: [1700000000, 1700003600] + }) + expect(prices.get(tokenKey)?.get(1700000000)).toBe(1) + expect(prices.get(tokenKey)?.get(1700003600)).toBe(1.001) + }) + + it('ignores future quotes outside strict timestamp tolerance', async () => { + const usdcKey = 'ethereum:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48' + const fetchStub = vi.fn().mockResolvedValue( + createBatchResponse({ + coins: { + [usdcKey]: { + symbol: 'USDC', + prices: [{ timestamp: 1700003600, price: 1.001, confidence: 0.99 }] + } + } + }) + ) + + vi.stubGlobal('fetch', fetchStub) + + const prices = await fetchHistoricalPrices( + [{ chainId: 1, address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' }], + [1700000000, 1700003600] + ) + + expect(prices.get(usdcKey)?.get(1700000000)).toBeUndefined() + expect(prices.get(usdcKey)?.get(1700003600)).toBe(1.001) + }) + + it('accepts day-bucket prices from nearby future quotes in utc_day mode', async () => { + const usdcKey = 'ethereum:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48' + + const fetchStub = vi.fn().mockResolvedValue( + createBatchResponse({ + coins: { + [usdcKey]: { + symbol: 'USDC', + prices: [{ timestamp: 1700003600, price: 1.001, confidence: 0.99 }] + } + } + }) + ) + + vi.stubGlobal('fetch', fetchStub) + + const prices = await fetchHistoricalPricesForTokenTimestamps( + [ + { + chainId: 1, + address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + timestamps: [1700000000] + } + ], + { resolution: 'utc_day' } + ) + + expect(prices.get(usdcKey)?.get(1700000000)).toBe(1.001) + }) +}) diff --git a/api/lib/holdings/services/defillama.ts b/api/lib/holdings/services/defillama.ts new file mode 100644 index 000000000..e51f0457e --- /dev/null +++ b/api/lib/holdings/services/defillama.ts @@ -0,0 +1,985 @@ +import { holdingsConfig } from '../config' +import { type DefiLlamaBatchResponse, SUPPORTED_CHAINS } from '../types' +import { debugError, debugLog } from './debug' + +type TDefiLlamaError = Error & { + code?: string + status?: number +} + +type THistoricalPriceProvider = 'defillama' | 'yearn-prices' +type THistoricalPriceProviderConfig = { + provider: THistoricalPriceProvider + label: string +} + +const RETRYABLE_ERROR_CODES = new Set([ + 'ECONNRESET', + 'ECONNREFUSED', + 'ConnectionRefused', + 'ETIMEDOUT', + 'EAI_AGAIN', + 'UND_ERR_SOCKET', + 'UND_ERR_CONNECT_TIMEOUT', + 'UND_ERR_HEADERS_TIMEOUT', + 'UND_ERR_ABORTED' +]) +const RETRYABLE_STATUS_CODES = new Set([408, 425, 429, 500, 502, 503, 504]) +const DEFAULT_TIMEOUT_MS = 4_000 +const DEFAULT_PRO_TIMEOUT_MS = 12_000 +const DEFAULT_YEARN_PRICES_TIMEOUT_MS = 8_000 +const DEFAULT_MAX_RETRIES = 2 +const DEFAULT_RETRY_DELAY_MS = 200 +const DEFAULT_MAX_REQUEST_URL_LENGTH = 3_500 +const DEFAULT_YEARN_PRICES_MAX_REQUEST_URL_LENGTH = 8_000 +const DEFAULT_YEARN_PRICES_BATCH_TIMESTAMP_SIZE = 45 +const DEFAULT_YEARN_PRICES_BATCH_MAX_PRICE_POINTS = 150 +const YEARN_PRICES_MAX_RANGE_DAYS = 366 +const MAX_REQUESTED_PRICE_DISTANCE_SECONDS = 60 * 60 +const MAX_DAILY_PRICE_DISTANCE_SECONDS = 60 * 60 * 24 +const SPLITTABLE_GET_STATUS_CODES = new Set([414, 431, 505]) + +type TCoinRequest = { chain: string; address: string; timestamps: number[] } +type TDefiLlamaFetchTuning = { + provider: THistoricalPriceProvider + useProApi: boolean + timeoutMs: number + maxRetries: number + retryDelayMs: number + timestampBatchSize: number + maxTokensPerBatch: number + maxTimestampsPerTokenPerBatch: number + maxPricePointsPerBatch: number + maxRequestUrlLength: number | null + parallelRequests: number + interGroupDelayMs: number +} + +type TDefiLlamaBatchRequest = { + url: string + init: RequestInit + variant: 'free_get' | 'pro_get' | 'yearn_prices_get' | 'yearn_prices_range_get' +} + +export type THistoricalPriceRequest = { + chainId: number + address: string + timestamps: number[] +} + +type TPriceTimestampMatch = { price: number; timestamp: number } | null +type TPriceTimestampMatcher = (priceMap: Map, targetTimestamp: number) => TPriceTimestampMatch +type THistoricalPriceFetchResolution = 'strict' | 'utc_day' +type THistoricalPriceFetchOptions = { + resolution?: THistoricalPriceFetchResolution +} +const HISTORICAL_PRICE_FETCH_FAILED_BATCHES = Symbol('historicalPriceFetchFailedBatches') +type THistoricalPriceResult = Map> & { + [HISTORICAL_PRICE_FETCH_FAILED_BATCHES]?: number +} + +export function getChainPrefix(chainId: number): string { + const chain = SUPPORTED_CHAINS.find((c) => c.id === chainId) + return chain?.defillamaPrefix || 'ethereum' +} + +function normalizeToUtcDayEnd(timestamp: number): number { + return Math.floor(timestamp / 86_400) * 86_400 + 86_399 +} + +function getNormalizedUtcDayEndTimestamps(timestamps: number[]): number[] { + return [...new Set(timestamps.map((timestamp) => normalizeToUtcDayEnd(timestamp)))].sort((a, b) => a - b) +} + +function getContiguousUtcDayEndRange(timestamps: number[]): [number, number] | null { + const dayEndTimestamps = getNormalizedUtcDayEndTimestamps(timestamps) + + if (dayEndTimestamps.length === 0 || dayEndTimestamps.length > YEARN_PRICES_MAX_RANGE_DAYS) { + return null + } + + const isContiguous = dayEndTimestamps.every((timestamp, index) => { + if (index === 0) { + return true + } + + return timestamp - dayEndTimestamps[index - 1]! === 86_400 + }) + + return isContiguous ? [dayEndTimestamps[0]!, dayEndTimestamps[dayEndTimestamps.length - 1]!] : null +} + +function shouldUseYearnPricesRangeRequest(coins: TCoinRequest[]): boolean { + return ( + coins.some((coin) => getNormalizedUtcDayEndTimestamps(coin.timestamps).length > 1) && + coins.every((coin) => getContiguousUtcDayEndRange(coin.timestamps) !== null) + ) +} + +function normalizeRequestedPriceProvider(value: string | undefined): 'auto' | THistoricalPriceProvider { + const normalized = (value ?? 'auto').trim().toLowerCase() + + if (normalized === 'yearn' || normalized === 'yearn-prices' || normalized === 'yearn_prices') { + return 'yearn-prices' + } + + if (normalized === 'defillama' || normalized === 'llama') { + return 'defillama' + } + + return 'auto' +} + +function getHistoricalPriceProviderConfig(): THistoricalPriceProviderConfig { + const requestedProvider = normalizeRequestedPriceProvider(process.env.HOLDINGS_PRICE_PROVIDER) + const hasYearnPricesConfig = + holdingsConfig.yearnPricesBaseUrl.length > 0 && holdingsConfig.yearnPricesApiKey.length > 0 + + if (requestedProvider === 'yearn-prices') { + if (!hasYearnPricesConfig) { + throw new Error( + 'yearn-prices provider requires YEARN_PRICES_BASE_URL and YEARN_PRICES_API_KEY or API_KEY_PORTFOLIO' + ) + } + + return { provider: 'yearn-prices', label: 'yearn-prices' } + } + + if (requestedProvider === 'auto' && hasYearnPricesConfig) { + return { provider: 'yearn-prices', label: 'yearn-prices' } + } + + return { provider: 'defillama', label: 'DefiLlama' } +} + +function wait(delayMs: number): Promise { + return new Promise((resolve) => setTimeout(resolve, delayMs)) +} + +function isRetryableError(error: unknown): boolean { + const defillamaError = error as Partial + const code = typeof defillamaError?.code === 'string' ? defillamaError.code : null + const status = typeof defillamaError?.status === 'number' ? defillamaError.status : null + const message = error instanceof Error ? error.message.toLowerCase() : '' + + return ( + (code !== null && RETRYABLE_ERROR_CODES.has(code)) || + (status !== null && RETRYABLE_STATUS_CODES.has(status)) || + message.includes('socket connection was closed unexpectedly') || + message.includes('unable to connect') || + message.includes('timed out') || + message.includes('timeout') + ) +} + +function isTimeoutError(error: unknown): boolean { + const candidate = error as Partial & { name?: string } + const code = typeof candidate?.code === 'string' ? candidate.code : null + const name = typeof candidate?.name === 'string' ? candidate.name : null + const message = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase() + + return ( + code === 'ETIMEDOUT' || + code === 'UND_ERR_CONNECT_TIMEOUT' || + code === 'UND_ERR_HEADERS_TIMEOUT' || + name === 'TimeoutError' || + message.includes('timed out') || + message.includes('timeout') + ) +} + +function chunkItems(items: T[], chunkSize: number): T[][] { + return Array.from({ length: Math.ceil(items.length / chunkSize) }, (_value, index) => + items.slice(index * chunkSize, index * chunkSize + chunkSize) + ) +} + +function countPricePoints(priceData: Map>): number { + return Array.from(priceData.values()).reduce((total, priceMap) => total + priceMap.size, 0) +} + +export function getHistoricalPriceFetchFailedBatches(priceData: Map>): number { + return (priceData as THistoricalPriceResult)[HISTORICAL_PRICE_FETCH_FAILED_BATCHES] ?? 0 +} + +function mergeFetchedPriceMaps(priceMaps: Array>>): Map> { + return priceMaps.reduce>>((mergedResult, priceMap) => { + priceMap.forEach((tokenPrices, tokenKey) => { + const existingTokenPrices = mergedResult.get(tokenKey) ?? new Map() + + tokenPrices.forEach((price, timestamp) => { + existingTokenPrices.set(timestamp, price) + }) + + mergedResult.set(tokenKey, existingTokenPrices) + }) + + return mergedResult + }, new Map>()) +} + +function mergeCoinRequests(coins: TCoinRequest[]): TCoinRequest[] { + const merged = coins.reduce>((result, coin) => { + const coinKey = `${coin.chain}:${coin.address.toLowerCase()}` + const existing = result.get(coinKey) + + if (!existing) { + result.set(coinKey, { + chain: coin.chain, + address: coin.address, + timestamps: [...coin.timestamps] + }) + return result + } + + existing.timestamps.push(...coin.timestamps) + existing.timestamps = [...new Set(existing.timestamps)].sort((a, b) => a - b) + return result + }, new Map()) + + return Array.from(merged.values()) +} + +function buildTokenRequests(tokensToFetch: TCoinRequest[], timestampBatchSize: number): TCoinRequest[] { + const timestampSlicesByToken = tokensToFetch.map((coin) => + chunkItems(coin.timestamps, timestampBatchSize).map((timestampBatch) => ({ + chain: coin.chain, + address: coin.address, + timestamps: timestampBatch + })) + ) + + return Array.from( + { length: Math.max(0, ...timestampSlicesByToken.map((timestampSlices) => timestampSlices.length)) }, + (_value, sliceIndex) => + timestampSlicesByToken.flatMap((timestampSlices) => { + const slice = timestampSlices[sliceIndex] + return slice ? [slice] : [] + }) + ).flat() +} + +function buildRequestBatches( + tokenRequests: TCoinRequest[], + tuning: TDefiLlamaFetchTuning +): Array<{ coinBatch: TCoinRequest[] }> { + const batches: Array<{ coinBatch: TCoinRequest[] }> = [] + let currentBatch: TCoinRequest[] = [] + let currentBatchPricePoints = 0 + let currentBatchTokenCounts = new Map() + + tokenRequests.forEach((tokenRequest) => { + const tokenKey = `${tokenRequest.chain}:${tokenRequest.address.toLowerCase()}` + const currentSlicesForToken = currentBatchTokenCounts.get(tokenKey) ?? 0 + const nextTokenCount = currentBatchTokenCounts.has(tokenKey) + ? currentBatchTokenCounts.size + : currentBatchTokenCounts.size + 1 + const nextPricePointCount = currentBatchPricePoints + tokenRequest.timestamps.length + const nextTokenTimestampCount = currentSlicesForToken * tuning.timestampBatchSize + tokenRequest.timestamps.length + const nextBatch = mergeCoinRequests([...currentBatch, tokenRequest]) + const nextBatchUrlLength = + tuning.maxRequestUrlLength === null + ? 0 + : (tuning.provider === 'yearn-prices' + ? buildYearnPricesRequest(nextBatch).url + : tuning.useProApi + ? buildProBatchHistoricalGetUrl(nextBatch) + : buildBatchHistoricalUrl(nextBatch) + ).length + + if ( + currentBatch.length > 0 && + (nextTokenCount > tuning.maxTokensPerBatch || + nextPricePointCount > tuning.maxPricePointsPerBatch || + nextTokenTimestampCount > tuning.maxTimestampsPerTokenPerBatch || + (tuning.maxRequestUrlLength !== null && nextBatchUrlLength > tuning.maxRequestUrlLength)) + ) { + batches.push({ coinBatch: mergeCoinRequests(currentBatch) }) + currentBatch = [] + currentBatchPricePoints = 0 + currentBatchTokenCounts = new Map() + } + + currentBatch.push(tokenRequest) + currentBatchPricePoints += tokenRequest.timestamps.length + currentBatchTokenCounts.set(tokenKey, (currentBatchTokenCounts.get(tokenKey) ?? 0) + 1) + }) + + if (currentBatch.length > 0) { + batches.push({ coinBatch: mergeCoinRequests(currentBatch) }) + } + + return batches +} + +function countRequestedPricePoints(coins: TCoinRequest[]): number { + return coins.reduce((total, coin) => total + coin.timestamps.length, 0) +} + +function getPriceAtTimestampWithinTolerance( + priceMap: Map, + targetTimestamp: number +): { price: number; timestamp: number } | null { + if (priceMap.has(targetTimestamp)) { + return { price: priceMap.get(targetTimestamp)!, timestamp: targetTimestamp } + } + + const closestPriorTimestamp = Array.from(priceMap.keys()) + .sort((left, right) => left - right) + .reduce((bestTimestamp, timestamp) => { + if (timestamp > targetTimestamp) { + return bestTimestamp + } + + return timestamp + }, null) + + if ( + closestPriorTimestamp === null || + targetTimestamp - closestPriorTimestamp > MAX_REQUESTED_PRICE_DISTANCE_SECONDS + ) { + return null + } + + return { price: priceMap.get(closestPriorTimestamp)!, timestamp: closestPriorTimestamp } +} + +function getPriceAtTimestampWithinDayWindow( + priceMap: Map, + targetTimestamp: number +): { price: number; timestamp: number } | null { + if (priceMap.has(targetTimestamp)) { + return { price: priceMap.get(targetTimestamp)!, timestamp: targetTimestamp } + } + + const bestMatch = Array.from(priceMap.keys()).reduce<{ + timestamp: number | null + distance: number + }>( + (best, timestamp) => { + const distance = Math.abs(timestamp - targetTimestamp) + + if (distance > MAX_DAILY_PRICE_DISTANCE_SECONDS) { + return best + } + + if ( + best.timestamp === null || + distance < best.distance || + (distance === best.distance && timestamp < targetTimestamp && (best.timestamp ?? Infinity) >= targetTimestamp) + ) { + return { + timestamp, + distance + } + } + + return best + }, + { + timestamp: null, + distance: Infinity + } + ) + + return bestMatch.timestamp === null + ? null + : { price: priceMap.get(bestMatch.timestamp)!, timestamp: bestMatch.timestamp } +} + +function getRequestedPriceMatcher(resolution: THistoricalPriceFetchResolution): TPriceTimestampMatcher { + return resolution === 'utc_day' ? getPriceAtTimestampWithinDayWindow : getPriceAtTimestampWithinTolerance +} + +type TMaterializedPrice = { + tokenKey: string + timestamp: number + price: number +} + +function materializeRequestedPrices( + coins: TCoinRequest[], + fetchedPrices: Map>, + matchPriceAtTimestamp: TPriceTimestampMatcher +): TMaterializedPrice[] { + return coins.flatMap((coin) => { + const tokenKey = `${coin.chain}:${coin.address.toLowerCase()}` + const fetchedPriceMap = fetchedPrices.get(tokenKey) ?? new Map() + + return coin.timestamps + .map((timestamp) => { + const matchedPrice = matchPriceAtTimestamp(fetchedPriceMap, timestamp) + return matchedPrice === null + ? null + : { + tokenKey, + timestamp, + price: matchedPrice.price + } + }) + .filter((entry): entry is TMaterializedPrice => entry !== null && entry.price > 0) + }) +} + +function buildCoinsParam( + coins: TCoinRequest[], + options: { normalizeTimestampsToDayEnd?: boolean } = {} +): Record { + return coins.reduce>((accumulator, coin) => { + const timestamps = options.normalizeTimestampsToDayEnd + ? getNormalizedUtcDayEndTimestamps(coin.timestamps) + : coin.timestamps + + accumulator[`${coin.chain}:${coin.address.toLowerCase()}`] = timestamps + return accumulator + }, {}) +} + +function buildRangeCoinsParam(coins: TCoinRequest[]): Record { + return coins.reduce>((accumulator, coin) => { + const range = getContiguousUtcDayEndRange(coin.timestamps) + + if (range !== null) { + accumulator[`${coin.chain}:${coin.address.toLowerCase()}`] = range + } + + return accumulator + }, {}) +} + +export function buildBatchHistoricalUrl(coins: TCoinRequest[]): string { + const encodedCoins = encodeURIComponent(JSON.stringify(buildCoinsParam(coins))) + return `${holdingsConfig.defillamaBaseUrl}/batchHistorical?coins=${encodedCoins}` +} + +function buildProBatchHistoricalGetUrl(coins: TCoinRequest[]): string { + const encodedCoins = encodeURIComponent(JSON.stringify(buildCoinsParam(coins))) + return `${holdingsConfig.defillamaProBaseUrl}/${holdingsConfig.defillamaApiKey}/coins/batchHistorical?coins=${encodedCoins}` +} + +function buildYearnPricesBatchHistoricalUrl(coins: TCoinRequest[]): string { + const encodedCoins = encodeURIComponent(JSON.stringify(buildCoinsParam(coins, { normalizeTimestampsToDayEnd: true }))) + const apiBaseUrl = holdingsConfig.yearnPricesBaseUrl.endsWith('/api') + ? holdingsConfig.yearnPricesBaseUrl + : `${holdingsConfig.yearnPricesBaseUrl}/api` + return `${apiBaseUrl}/prices/batchHistorical?coins=${encodedCoins}` +} + +function buildYearnPricesRangeHistoricalUrl(coins: TCoinRequest[]): string { + const encodedCoins = encodeURIComponent(JSON.stringify(buildRangeCoinsParam(coins))) + const apiBaseUrl = holdingsConfig.yearnPricesBaseUrl.endsWith('/api') + ? holdingsConfig.yearnPricesBaseUrl + : `${holdingsConfig.yearnPricesBaseUrl}/api` + return `${apiBaseUrl}/prices/rangeHistorical?coins=${encodedCoins}` +} + +function buildYearnPricesRequest(coins: TCoinRequest[]): Pick { + if (shouldUseYearnPricesRangeRequest(coins)) { + return { + url: buildYearnPricesRangeHistoricalUrl(coins), + variant: 'yearn_prices_range_get' + } + } + + return { + url: buildYearnPricesBatchHistoricalUrl(coins), + variant: 'yearn_prices_get' + } +} + +function buildBatchHistoricalRequests(coins: TCoinRequest[], tuning: TDefiLlamaFetchTuning): TDefiLlamaBatchRequest[] { + if (tuning.provider === 'yearn-prices') { + const request = buildYearnPricesRequest(coins) + return [ + { + url: request.url, + init: { + headers: { + Authorization: `Bearer ${holdingsConfig.yearnPricesApiKey}` + } + }, + variant: request.variant + } + ] + } + + if (holdingsConfig.defillamaApiKey.length === 0) { + return [ + { + url: buildBatchHistoricalUrl(coins), + init: {}, + variant: 'free_get' + } + ] + } + + return [ + { + url: buildProBatchHistoricalGetUrl(coins), + init: {}, + variant: 'pro_get' + }, + { + url: buildBatchHistoricalUrl(coins), + init: {}, + variant: 'free_get' + } + ] +} + +function abbreviateTokenAddress(address: string): string { + const normalizedAddress = address.toLowerCase() + + if (normalizedAddress.length <= 9) { + return normalizedAddress + } + + return `${normalizedAddress.slice(0, 4)}..${normalizedAddress.slice(-3)}` +} + +function buildBatchDebugSummary( + coinBatch: TCoinRequest[], + uniqueTimestamps: number[] +): { + firstTimestamp: number | null + lastTimestamp: number | null + firstToken: string | null + lastToken: string | null +} { + const firstCoin = coinBatch[0] + const lastCoin = coinBatch.length > 0 ? coinBatch[coinBatch.length - 1] : undefined + + return { + firstTimestamp: uniqueTimestamps[0] ?? null, + lastTimestamp: uniqueTimestamps.length > 0 ? uniqueTimestamps[uniqueTimestamps.length - 1] : null, + firstToken: firstCoin ? abbreviateTokenAddress(firstCoin.address) : null, + lastToken: lastCoin ? abbreviateTokenAddress(lastCoin.address) : null + } +} + +function isSplittableGetError(error: unknown): boolean { + const errorStatus = (error as Partial)?.status + const status = typeof errorStatus === 'number' ? errorStatus : null + + return status !== null && SPLITTABLE_GET_STATUS_CODES.has(status) +} + +function shouldSplitBatchAfterRequestError(error: unknown, tuning: TDefiLlamaFetchTuning): boolean { + return isSplittableGetError(error) || (tuning.provider === 'yearn-prices' && isTimeoutError(error)) +} + +function splitCoinBatch( + coinBatch: TCoinRequest[] +): { batches: [TCoinRequest[], TCoinRequest[]]; splitMode: string } | null { + if (coinBatch.length > 1) { + const midpoint = Math.ceil(coinBatch.length / 2) + return { + batches: [coinBatch.slice(0, midpoint), coinBatch.slice(midpoint)], + splitMode: 'coin_batch' + } + } + + const [coinRequest] = coinBatch + if (!coinRequest || coinRequest.timestamps.length <= 1) { + return null + } + + const midpoint = Math.ceil(coinRequest.timestamps.length / 2) + return { + batches: [ + [{ ...coinRequest, timestamps: coinRequest.timestamps.slice(0, midpoint) }], + [{ ...coinRequest, timestamps: coinRequest.timestamps.slice(midpoint) }] + ], + splitMode: 'timestamp_batch' + } +} + +function getDefiLlamaFetchTuning(providerConfig: THistoricalPriceProviderConfig): TDefiLlamaFetchTuning { + if (providerConfig.provider === 'yearn-prices') { + return { + provider: 'yearn-prices', + useProApi: false, + timeoutMs: DEFAULT_YEARN_PRICES_TIMEOUT_MS, + maxRetries: DEFAULT_MAX_RETRIES, + retryDelayMs: DEFAULT_RETRY_DELAY_MS, + timestampBatchSize: DEFAULT_YEARN_PRICES_BATCH_TIMESTAMP_SIZE, + maxTokensPerBatch: 50, + maxTimestampsPerTokenPerBatch: DEFAULT_YEARN_PRICES_BATCH_TIMESTAMP_SIZE, + maxPricePointsPerBatch: DEFAULT_YEARN_PRICES_BATCH_MAX_PRICE_POINTS, + maxRequestUrlLength: DEFAULT_YEARN_PRICES_MAX_REQUEST_URL_LENGTH, + parallelRequests: 4, + interGroupDelayMs: 0 + } + } + + if (holdingsConfig.defillamaApiKey.length > 0) { + return { + provider: 'defillama', + useProApi: true, + timeoutMs: DEFAULT_PRO_TIMEOUT_MS, + maxRetries: DEFAULT_MAX_RETRIES, + retryDelayMs: DEFAULT_RETRY_DELAY_MS / 2, + timestampBatchSize: 40, + maxTokensPerBatch: 25, + maxTimestampsPerTokenPerBatch: 40, + maxPricePointsPerBatch: 600, + maxRequestUrlLength: DEFAULT_MAX_REQUEST_URL_LENGTH, + parallelRequests: 10, + interGroupDelayMs: 0 + } + } + + return { + provider: 'defillama', + useProApi: false, + timeoutMs: DEFAULT_TIMEOUT_MS, + maxRetries: DEFAULT_MAX_RETRIES, + retryDelayMs: DEFAULT_RETRY_DELAY_MS, + timestampBatchSize: 10, + maxTokensPerBatch: 50, + maxTimestampsPerTokenPerBatch: 50, + maxPricePointsPerBatch: 500, + maxRequestUrlLength: null, + parallelRequests: 2, + interGroupDelayMs: 50 + } +} + +export function parseDefiLlamaResponse( + response: DefiLlamaBatchResponse, + _requestedTimestamps: number[] +): Map> { + return Object.entries(response.coins).reduce>>((result, [coinKey, coinData]) => { + const priceMap = coinData.prices.reduce>((map, point) => { + map.set(point.timestamp, point.price) + return map + }, new Map()) + + result.set(coinKey.toLowerCase(), priceMap) + return result + }, new Map>()) +} + +async function fetchBatch( + coinBatch: TCoinRequest[], + tuning: TDefiLlamaFetchTuning, + attempt = 0 +): Promise>> { + const uniqueTimestamps = [...new Set(coinBatch.flatMap((coin) => coin.timestamps))].sort((a, b) => a - b) + const requestedPricePoints = countRequestedPricePoints(coinBatch) + const requests = buildBatchHistoricalRequests(coinBatch, tuning) + const batchDebugSummary = buildBatchDebugSummary(coinBatch, uniqueTimestamps) + const requestDetails = requests.map((request) => ({ + variant: request.variant, + method: request.init.method ?? 'GET', + urlLength: request.url.length + })) + debugLog('prices', 'fetching price batch', { + attempt: attempt + 1, + provider: tuning.provider, + tokenCount: coinBatch.length, + timestampCount: uniqueTimestamps.length, + pricePointCount: requestedPricePoints, + ...batchDebugSummary, + useProApi: tuning.useProApi, + requestVariants: requests.map((request) => request.variant), + requestDetails + }) + + try { + const parsed = await requests.reduce> | null>>( + async (parsedPromise, request, requestIndex) => { + const existingParsed = await parsedPromise + + if (existingParsed !== null) { + return existingParsed + } + + try { + const response = await fetch(request.url, { + ...request.init, + signal: AbortSignal.timeout(tuning.timeoutMs) + }) + + if (!response.ok) { + const error = new Error(`DefiLlama batchHistorical request failed: ${response.status}`) as TDefiLlamaError + error.status = response.status + throw error + } + + const data = (await response.json()) as DefiLlamaBatchResponse + return parseDefiLlamaResponse(data, uniqueTimestamps) + } catch (error) { + if (shouldSplitBatchAfterRequestError(error, tuning)) { + const splitBatch = splitCoinBatch(coinBatch) + + if (splitBatch !== null) { + debugError('prices', 'splitting price batch after request failed', error, { + attempt: attempt + 1, + provider: tuning.provider, + tokenCount: coinBatch.length, + timestampCount: uniqueTimestamps.length, + pricePointCount: requestedPricePoints, + ...batchDebugSummary, + useProApi: tuning.useProApi, + requestVariant: request.variant, + requestMethod: request.init.method ?? 'GET', + requestUrlLength: request.url.length, + splitMode: splitBatch.splitMode + }) + + const splitResults = await Promise.all( + splitBatch.batches.map((splitCoinRequests) => fetchBatch(splitCoinRequests, tuning, attempt)) + ) + return mergeFetchedPriceMaps(splitResults) + } + } + + if (requestIndex < requests.length - 1) { + debugError('prices', 'price batch request variant failed', error, { + attempt: attempt + 1, + provider: tuning.provider, + tokenCount: coinBatch.length, + timestampCount: uniqueTimestamps.length, + pricePointCount: requestedPricePoints, + ...batchDebugSummary, + useProApi: tuning.useProApi, + requestVariant: request.variant, + requestMethod: request.init.method ?? 'GET', + requestUrlLength: request.url.length + }) + return null + } + + throw error + } + }, + Promise.resolve(null) + ) + + if (parsed === null) { + throw new Error('DefiLlama batch request resolved without a response') + } + + debugLog('prices', 'fetched price batch', { + attempt: attempt + 1, + provider: tuning.provider, + tokenCount: coinBatch.length, + timestampCount: uniqueTimestamps.length, + pricePointCount: requestedPricePoints, + ...batchDebugSummary, + pricePoints: countPricePoints(parsed), + useProApi: tuning.useProApi, + requestVariants: requests.map((request) => request.variant), + requestDetails + }) + return parsed + } catch (error) { + if (attempt >= tuning.maxRetries || !isRetryableError(error)) { + debugError('prices', 'price batch failed', error, { + attempt: attempt + 1, + provider: tuning.provider, + tokenCount: coinBatch.length, + timestampCount: uniqueTimestamps.length, + pricePointCount: requestedPricePoints, + ...batchDebugSummary, + useProApi: tuning.useProApi + }) + throw error + } + + debugError('prices', 'retrying price batch', error, { + nextAttempt: attempt + 2, + provider: tuning.provider, + tokenCount: coinBatch.length, + timestampCount: uniqueTimestamps.length, + pricePointCount: requestedPricePoints, + ...batchDebugSummary, + useProApi: tuning.useProApi + }) + await wait(tuning.retryDelayMs * 2 ** attempt) + return fetchBatch(coinBatch, tuning, attempt + 1) + } +} + +export async function fetchHistoricalPricesForTokenTimestamps( + requests: THistoricalPriceRequest[], + options: THistoricalPriceFetchOptions = {} +): Promise>> { + const providerConfig = getHistoricalPriceProviderConfig() + const resolution = options.resolution ?? 'strict' + const matchPriceAtTimestamp = + providerConfig.provider === 'yearn-prices' + ? getPriceAtTimestampWithinDayWindow + : getRequestedPriceMatcher(resolution) + const tuning = getDefiLlamaFetchTuning(providerConfig) + const coins = mergeCoinRequests( + requests + .map((request) => ({ + chain: getChainPrefix(request.chainId), + address: request.address, + timestamps: [...new Set(request.timestamps)].sort((a, b) => a - b) + })) + .filter((request) => request.timestamps.length > 0) + ) + const tokenKeys = coins.map((coin) => `${coin.chain}:${coin.address.toLowerCase()}`) + const requestedTimestamps = [...new Set(coins.flatMap((coin) => coin.timestamps))].sort((a, b) => a - b) + const requestedPricePoints = countRequestedPricePoints(coins) + + debugLog('prices', 'starting historical price fetch', { + provider: providerConfig.provider, + tokens: tokenKeys.length, + timestamps: requestedTimestamps.length, + pricePointCount: requestedPricePoints, + resolution, + useProApi: tuning.useProApi, + parallelRequests: tuning.parallelRequests + }) + + const result = tokenKeys.reduce>>((priceResult, tokenKey) => { + priceResult.set(tokenKey, new Map()) + return priceResult + }, new Map>()) + + if (coins.length === 0 || requestedTimestamps.length === 0) { + return result + } + + const fetchStats = { successfulBatches: 0, failedBatches: 0 } + const fetchPriceGroup = async (coinsToFetch: TCoinRequest[]): Promise => { + const shouldUseRangeRequests = tuning.provider === 'yearn-prices' && shouldUseYearnPricesRangeRequest(coinsToFetch) + const effectiveTuning = shouldUseRangeRequests + ? { + ...tuning, + timestampBatchSize: YEARN_PRICES_MAX_RANGE_DAYS, + maxTimestampsPerTokenPerBatch: YEARN_PRICES_MAX_RANGE_DAYS, + maxPricePointsPerBatch: tuning.maxTokensPerBatch * YEARN_PRICES_MAX_RANGE_DAYS + } + : tuning + const tokenRequests = buildTokenRequests(coinsToFetch, effectiveTuning.timestampBatchSize) + const batches = buildRequestBatches(tokenRequests, effectiveTuning) + const batchGroups = chunkItems(batches, effectiveTuning.parallelRequests) + const allRequestedTimestamps = [...new Set(coinsToFetch.flatMap((coin) => coin.timestamps))].sort((a, b) => a - b) + const groupPricePoints = countRequestedPricePoints(coinsToFetch) + + debugLog('prices', 'prepared price fetch batches', { + provider: providerConfig.provider, + tokensToFetch: coinsToFetch.length, + uniqueTimestamps: allRequestedTimestamps.length, + pricePointCount: groupPricePoints, + tokenRequests: tokenRequests.length, + batches: batches.length, + batchGroups: batchGroups.length, + maxTokensPerBatch: effectiveTuning.maxTokensPerBatch, + maxPricePointsPerBatch: effectiveTuning.maxPricePointsPerBatch, + maxTimestampsPerTokenPerBatch: effectiveTuning.maxTimestampsPerTokenPerBatch, + maxRequestUrlLength: effectiveTuning.maxRequestUrlLength, + useProApi: effectiveTuning.useProApi, + useRangeRequests: shouldUseRangeRequests + }) + + await batchGroups.reduce>(async (previousGroupPromise, batchGroup, groupIndex) => { + await previousGroupPromise + + const batchResults = await Promise.allSettled( + batchGroup.map((batch) => fetchBatch(batch.coinBatch, effectiveTuning)) + ) + + batchResults.forEach((batchResult, batchIndex) => { + const batch = batchGroup[batchIndex] + + if (batchResult.status === 'rejected') { + fetchStats.failedBatches += 1 + const batchTimestamps = [...new Set(batch.coinBatch.flatMap((coin) => coin.timestamps))].sort((a, b) => a - b) + const batchPricePoints = batch.coinBatch.reduce((total, coin) => total + coin.timestamps.length, 0) + console.error( + `[${providerConfig.label}] Failed to fetch prices for ${batch.coinBatch.length} tokens and ${batchPricePoints} token-timestamp pairs:`, + batchResult.reason + ) + debugError('prices', 'price batch group member failed', batchResult.reason, { + provider: providerConfig.provider, + tokenCount: batch.coinBatch.length, + timestampCount: batchTimestamps.length, + pricePointCount: batchPricePoints, + firstTimestamp: batchTimestamps[0] ?? null, + lastTimestamp: batchTimestamps.length > 0 ? batchTimestamps[batchTimestamps.length - 1] : null + }) + return + } + + fetchStats.successfulBatches += 1 + + const materializedPrices = materializeRequestedPrices(batch.coinBatch, batchResult.value, matchPriceAtTimestamp) + materializedPrices.forEach(({ tokenKey, timestamp, price }) => { + if (!result.has(tokenKey)) { + result.set(tokenKey, new Map()) + } + + const existingMap = result.get(tokenKey)! + existingMap.set(timestamp, price) + }) + }) + + if (groupIndex < batchGroups.length - 1 && effectiveTuning.interGroupDelayMs > 0) { + await wait(effectiveTuning.interGroupDelayMs) + } + }, Promise.resolve()) + } + + await fetchPriceGroup(coins) + + if (fetchStats.successfulBatches === 0 && countPricePoints(result) === 0) { + throw new Error(`Failed to fetch token prices from ${providerConfig.label}`) + } + + if (fetchStats.failedBatches > 0) { + Object.defineProperty(result, HISTORICAL_PRICE_FETCH_FAILED_BATCHES, { + value: fetchStats.failedBatches, + enumerable: false + }) + } + + debugLog('prices', 'completed historical price fetch', { + provider: providerConfig.provider, + successfulBatches: fetchStats.successfulBatches, + failedBatches: fetchStats.failedBatches, + totalPricePoints: countPricePoints(result), + resolution + }) + + return result +} + +export async function fetchHistoricalPrices( + tokens: Array<{ chainId: number; address: string }>, + timestamps: number[] +): Promise>> { + return fetchHistoricalPricesForTokenTimestamps( + tokens.map((token) => ({ + ...token, + timestamps + })) + ) +} + +export function getPriceAtTimestamp(priceMap: Map, targetTimestamp: number): number { + if (priceMap.has(targetTimestamp)) { + return priceMap.get(targetTimestamp)! + } + + const timestamps = Array.from(priceMap.keys()).sort((a, b) => a - b) + + if (timestamps.length === 0) { + return 0 + } + + let closestPriorTimestamp: number | null = null + for (const timestamp of timestamps) { + if (timestamp > targetTimestamp) { + break + } + closestPriorTimestamp = timestamp + } + + return closestPriorTimestamp !== null ? priceMap.get(closestPriorTimestamp) || 0 : 0 +} diff --git a/api/lib/holdings/services/graphql.test.ts b/api/lib/holdings/services/graphql.test.ts new file mode 100644 index 000000000..1bd95e513 --- /dev/null +++ b/api/lib/holdings/services/graphql.test.ts @@ -0,0 +1,279 @@ +import { getAddress } from 'viem' +import { afterEach, describe, expect, it, vi } from 'vitest' + +const USER = '0x93a62da5a14c80f265dabc077fcee437b1a0efde' +const ROUTER = '0x1111111111111111111111111111111111111111' +const TX_HASH = '0xrouter-stake' +const VAULT = '0xbe53a109b494e5c9f97b9cd39fe969be68bf6204' + +function createGraphqlResponse(data: Record): Response { + return new Response(JSON.stringify({ data }), { + status: 200, + headers: { 'content-type': 'application/json' } + }) +} + +function getEmptyResultKey(query: string): string { + return query.includes('V2Deposit') + ? 'V2Deposit' + : query.includes('V2Withdraw') + ? 'V2Withdraw' + : query.includes('Withdraw') + ? 'Withdraw' + : query.includes('Transfer') + ? 'Transfer' + : 'Deposit' +} + +async function importGraphqlModule() { + vi.resetModules() + return import('./graphql') +} + +describe('fetchUserEvents', () => { + afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllGlobals() + }) + + it('falls back to sequential pagination when aggregate counts are unavailable', async () => { + const transferBatches = [ + Array.from({ length: 1000 }, (_, index) => ({ + id: `aggregate-transfer-in-${index}`, + vaultAddress: VAULT, + chainId: 1, + blockNumber: index + 1, + blockTimestamp: 200 + index, + logIndex: index, + transactionHash: `${TX_HASH}-${index}`, + transactionFrom: ROUTER, + sender: ROUTER, + receiver: USER, + value: '900' + })), + [ + { + id: 'aggregate-transfer-in-last', + vaultAddress: VAULT, + chainId: 1, + blockNumber: 1001, + blockTimestamp: 1201, + logIndex: 1000, + transactionHash: `${TX_HASH}-last`, + transactionFrom: ROUTER, + sender: ROUTER, + receiver: USER, + value: '900' + } + ] + ] + + const fetchStub = vi.fn(async (_input: string | URL | Request, init?: RequestInit) => { + const body = JSON.parse(String(init?.body ?? '{}')) as { + query: string + variables: Record + } + const query = body.query + const variables = body.variables + + if (query.includes('GetUserEventCountsAggregate')) { + return new Response( + JSON.stringify({ + errors: [{ message: "field 'Deposit_aggregate' not found in type: 'query_root'" }] + }), + { + status: 200, + headers: { 'content-type': 'application/json' } + } + ) + } + + if (query.includes('GetTransfersIn')) { + const offset = Number(variables.offset ?? 0) + const batch = offset === 0 ? transferBatches[0] : offset === 1000 ? transferBatches[1] : [] + + return createGraphqlResponse({ + Transfer: batch + }) + } + + if ( + query.includes('GetDeposits(') || + query.includes('GetWithdrawals(') || + query.includes('GetTransfersOut') || + query.includes('GetV2Deposits(') || + query.includes('GetV2Withdrawals(') + ) { + return createGraphqlResponse({ [getEmptyResultKey(query)]: [] }) + } + + throw new Error(`Unexpected query: ${query}`) + }) + + vi.stubGlobal('fetch', fetchStub) + + const { fetchUserEvents } = await importGraphqlModule() + const events = await fetchUserEvents(USER, 'all', undefined, 'parallel') + + expect(events.transfersIn).toHaveLength(1001) + expect(events.transfersIn[0]).toEqual( + expect.objectContaining({ + id: 'aggregate-transfer-in-0' + }) + ) + expect(events.transfersIn[1000]).toEqual( + expect.objectContaining({ + id: 'aggregate-transfer-in-last' + }) + ) + expect(fetchStub).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + body: expect.stringContaining('GetUserEventCountsAggregate') + }) + ) + expect( + fetchStub.mock.calls.filter(([, init]) => + String((init as RequestInit | undefined)?.body ?? '').includes('GetTransfersIn') + ) + ).toHaveLength(2) + }) + + it('supports fetching address events in a single query without aggregate preflight', async () => { + const fetchStub = vi.fn(async (_input: string | URL | Request, init?: RequestInit) => { + const body = JSON.parse(String(init?.body ?? '{}')) as { + query: string + variables: Record + } + const query = body.query + const variables = body.variables + + if (query.includes('GetUserEventCountsAggregate')) { + throw new Error('Aggregate preflight should be skipped in paginationMode=all') + } + + if (query.includes('GetTransfersIn')) { + expect(query).toContain('receiver: { _eq: $receiver }') + expect(variables.receiver).toBe(getAddress(USER)) + expect(variables.limit).toBe(50000) + expect(variables.offset).toBe(0) + + return createGraphqlResponse({ + Transfer: [ + { + id: 'single-query-transfer-in', + vaultAddress: VAULT, + chainId: 1, + blockNumber: 2, + blockTimestamp: 200, + logIndex: 2, + transactionHash: TX_HASH, + transactionFrom: ROUTER, + sender: ROUTER, + receiver: USER, + value: '900' + } + ] + }) + } + + if ( + query.includes('GetDeposits(') || + query.includes('GetWithdrawals(') || + query.includes('GetTransfersOut') || + query.includes('GetV2Deposits(') || + query.includes('GetV2Withdrawals(') + ) { + return createGraphqlResponse({ [getEmptyResultKey(query)]: [] }) + } + + throw new Error(`Unexpected query: ${query}`) + }) + + vi.stubGlobal('fetch', fetchStub) + + const { fetchUserEvents } = await importGraphqlModule() + const events = await fetchUserEvents(USER, 'all', undefined, 'parallel', 'all') + + expect(events.transfersIn).toEqual([ + expect.objectContaining({ + id: 'single-query-transfer-in' + }) + ]) + expect( + fetchStub.mock.calls.some(([, init]) => + String((init as RequestInit | undefined)?.body ?? '').includes('GetUserEventCountsAggregate') + ) + ).toBe(false) + }) + + it('reuses in-flight address-scoped fetches for matching requests', async () => { + const fetchStub = vi.fn(async (_input: string | URL | Request, init?: RequestInit) => { + const body = JSON.parse(String(init?.body ?? '{}')) as { + query: string + } + const query = body.query + + if (query.includes('GetTransfersIn')) { + return createGraphqlResponse({ + Transfer: [ + { + id: 'shared-transfer-in', + vaultAddress: VAULT, + chainId: 1, + blockNumber: 2, + blockTimestamp: 200, + logIndex: 2, + transactionHash: TX_HASH, + transactionFrom: ROUTER, + sender: ROUTER, + receiver: USER, + value: '900' + } + ] + }) + } + + if ( + query.includes('GetDeposits(') || + query.includes('GetWithdrawals(') || + query.includes('GetTransfersOut') || + query.includes('GetV2Deposits(') || + query.includes('GetV2Withdrawals(') + ) { + return createGraphqlResponse({ [getEmptyResultKey(query)]: [] }) + } + + throw new Error(`Unexpected query: ${query}`) + }) + + vi.stubGlobal('fetch', fetchStub) + + const { fetchUserEvents } = await importGraphqlModule() + const [leftEvents, rightEvents] = await Promise.all([ + fetchUserEvents(USER, 'all', 123456), + fetchUserEvents(USER, 'all', 123456) + ]) + + expect(leftEvents.transfersIn).toEqual([ + expect.objectContaining({ + id: 'shared-transfer-in' + }) + ]) + expect(rightEvents.transfersIn).toEqual([ + expect.objectContaining({ + id: 'shared-transfer-in' + }) + ]) + expect( + fetchStub.mock.calls.filter(([, init]) => + String((init as RequestInit | undefined)?.body ?? '').includes('GetTransfersIn') + ) + ).toHaveLength(1) + expect( + fetchStub.mock.calls.filter(([, init]) => + String((init as RequestInit | undefined)?.body ?? '').includes('GetDeposits(') + ) + ).toHaveLength(1) + }) +}) diff --git a/api/lib/holdings/services/graphql.ts b/api/lib/holdings/services/graphql.ts new file mode 100644 index 000000000..c63038bb8 --- /dev/null +++ b/api/lib/holdings/services/graphql.ts @@ -0,0 +1,1334 @@ +import { getAddress } from 'viem' +import { holdingsConfig } from '../config' +import type { DepositEvent, TransferEvent, UserEvents, V2DepositEvent, V2WithdrawEvent, WithdrawEvent } from '../types' +import { debugError, debugLog } from './debug' + +// V3 Vault Queries (with optional maxTimestamp filter) +const DEPOSITS_QUERY = ` + query GetDeposits($owner: String!, $limit: Int!, $offset: Int!, $maxTimestamp: Int) { + Deposit(where: { owner: { _eq: $owner }, blockTimestamp: { _lte: $maxTimestamp } }, order_by: [{ blockTimestamp: asc }, { blockNumber: asc }, { logIndex: asc }], limit: $limit, offset: $offset) { + id + vaultAddress + chainId + blockNumber + blockTimestamp + logIndex + transactionHash + transactionFrom + owner + sender + assets + shares + } + } +` + +const WITHDRAWALS_QUERY = ` + query GetWithdrawals($owner: String!, $limit: Int!, $offset: Int!, $maxTimestamp: Int) { + Withdraw(where: { owner: { _eq: $owner }, blockTimestamp: { _lte: $maxTimestamp } }, order_by: [{ blockTimestamp: asc }, { blockNumber: asc }, { logIndex: asc }], limit: $limit, offset: $offset) { + id + vaultAddress + chainId + blockNumber + blockTimestamp + logIndex + transactionHash + transactionFrom + owner + assets + shares + } + } +` + +const TRANSFERS_IN_QUERY = ` + query GetTransfersIn($receiver: String!, $limit: Int!, $offset: Int!, $maxTimestamp: Int) { + Transfer(where: { receiver: { _eq: $receiver }, blockTimestamp: { _lte: $maxTimestamp } }, order_by: [{ blockTimestamp: asc }, { blockNumber: asc }, { logIndex: asc }], limit: $limit, offset: $offset) { + id + vaultAddress + chainId + blockNumber + blockTimestamp + logIndex + transactionHash + transactionFrom + sender + receiver + value + } + } +` + +const TRANSFERS_OUT_QUERY = ` + query GetTransfersOut($sender: String!, $limit: Int!, $offset: Int!, $maxTimestamp: Int) { + Transfer(where: { sender: { _eq: $sender }, blockTimestamp: { _lte: $maxTimestamp } }, order_by: [{ blockTimestamp: asc }, { blockNumber: asc }, { logIndex: asc }], limit: $limit, offset: $offset) { + id + vaultAddress + chainId + blockNumber + blockTimestamp + logIndex + transactionHash + transactionFrom + sender + receiver + value + } + } +` + +const BATCH_SIZE = 1000 +const SINGLE_QUERY_LIMIT = 50000 + +// V2 Vault Queries (with optional maxTimestamp filter) +const V2_DEPOSITS_QUERY = ` + query GetV2Deposits($recipient: String!, $limit: Int!, $offset: Int!, $maxTimestamp: Int) { + V2Deposit(where: { recipient: { _eq: $recipient }, blockTimestamp: { _lte: $maxTimestamp } }, order_by: [{ blockTimestamp: asc }, { blockNumber: asc }, { logIndex: asc }], limit: $limit, offset: $offset) { + id + vaultAddress + chainId + blockNumber + blockTimestamp + logIndex + transactionHash + transactionFrom + recipient + amount + shares + } + } +` + +const V2_WITHDRAWALS_QUERY = ` + query GetV2Withdrawals($recipient: String!, $limit: Int!, $offset: Int!, $maxTimestamp: Int) { + V2Withdraw(where: { recipient: { _eq: $recipient }, blockTimestamp: { _lte: $maxTimestamp } }, order_by: [{ blockTimestamp: asc }, { blockNumber: asc }, { logIndex: asc }], limit: $limit, offset: $offset) { + id + vaultAddress + chainId + blockNumber + blockTimestamp + logIndex + transactionHash + transactionFrom + recipient + amount + shares + } + } +` + +const RECENT_DEPOSITS_QUERY = ` + query GetRecentDeposits($owner: String!, $limit: Int!, $offset: Int!, $maxTimestamp: Int) { + Deposit(where: { owner: { _eq: $owner }, blockTimestamp: { _lte: $maxTimestamp } }, order_by: [{ blockTimestamp: desc }, { blockNumber: desc }, { logIndex: desc }], limit: $limit, offset: $offset) { + id + vaultAddress + chainId + blockNumber + blockTimestamp + logIndex + transactionHash + transactionFrom + owner + sender + assets + shares + } + } +` + +const RECENT_WITHDRAWALS_QUERY = ` + query GetRecentWithdrawals($owner: String!, $limit: Int!, $offset: Int!, $maxTimestamp: Int) { + Withdraw(where: { owner: { _eq: $owner }, blockTimestamp: { _lte: $maxTimestamp } }, order_by: [{ blockTimestamp: desc }, { blockNumber: desc }, { logIndex: desc }], limit: $limit, offset: $offset) { + id + vaultAddress + chainId + blockNumber + blockTimestamp + logIndex + transactionHash + transactionFrom + owner + assets + shares + } + } +` + +const RECENT_V2_DEPOSITS_QUERY = ` + query GetRecentV2Deposits($recipient: String!, $limit: Int!, $offset: Int!, $maxTimestamp: Int) { + V2Deposit(where: { recipient: { _eq: $recipient }, blockTimestamp: { _lte: $maxTimestamp } }, order_by: [{ blockTimestamp: desc }, { blockNumber: desc }, { logIndex: desc }], limit: $limit, offset: $offset) { + id + vaultAddress + chainId + blockNumber + blockTimestamp + logIndex + transactionHash + transactionFrom + recipient + amount + shares + } + } +` + +const RECENT_V2_WITHDRAWALS_QUERY = ` + query GetRecentV2Withdrawals($recipient: String!, $limit: Int!, $offset: Int!, $maxTimestamp: Int) { + V2Withdraw(where: { recipient: { _eq: $recipient }, blockTimestamp: { _lte: $maxTimestamp } }, order_by: [{ blockTimestamp: desc }, { blockNumber: desc }, { logIndex: desc }], limit: $limit, offset: $offset) { + id + vaultAddress + chainId + blockNumber + blockTimestamp + logIndex + transactionHash + transactionFrom + recipient + amount + shares + } + } +` + +const RECENT_TRANSFERS_IN_QUERY = ` + query GetRecentTransfersIn($receiver: String!, $limit: Int!, $offset: Int!, $maxTimestamp: Int) { + Transfer(where: { receiver: { _eq: $receiver }, blockTimestamp: { _lte: $maxTimestamp } }, order_by: [{ blockTimestamp: desc }, { blockNumber: desc }, { logIndex: desc }], limit: $limit, offset: $offset) { + id + vaultAddress + chainId + blockNumber + blockTimestamp + logIndex + transactionHash + transactionFrom + sender + receiver + value + } + } +` + +const RECENT_TRANSFERS_OUT_QUERY = ` + query GetRecentTransfersOut($sender: String!, $limit: Int!, $offset: Int!, $maxTimestamp: Int) { + Transfer(where: { sender: { _eq: $sender }, blockTimestamp: { _lte: $maxTimestamp } }, order_by: [{ blockTimestamp: desc }, { blockNumber: desc }, { logIndex: desc }], limit: $limit, offset: $offset) { + id + vaultAddress + chainId + blockNumber + blockTimestamp + logIndex + transactionHash + transactionFrom + sender + receiver + value + } + } +` + +const DEPOSITS_BY_TX_HASHES_QUERY = ` + query GetDepositsByTransactionHashes($chainId: Int!, $transactionHashes: [String!]!, $limit: Int!, $offset: Int!, $maxTimestamp: Int) { + Deposit(where: { chainId: { _eq: $chainId }, transactionHash: { _in: $transactionHashes }, blockTimestamp: { _lte: $maxTimestamp } }, order_by: [{ blockTimestamp: asc }, { blockNumber: asc }, { logIndex: asc }], limit: $limit, offset: $offset) { + id + vaultAddress + chainId + blockNumber + blockTimestamp + logIndex + transactionHash + transactionFrom + owner + sender + assets + shares + } + } +` + +const WITHDRAWALS_BY_TX_HASHES_QUERY = ` + query GetWithdrawalsByTransactionHashes($chainId: Int!, $transactionHashes: [String!]!, $limit: Int!, $offset: Int!, $maxTimestamp: Int) { + Withdraw(where: { chainId: { _eq: $chainId }, transactionHash: { _in: $transactionHashes }, blockTimestamp: { _lte: $maxTimestamp } }, order_by: [{ blockTimestamp: asc }, { blockNumber: asc }, { logIndex: asc }], limit: $limit, offset: $offset) { + id + vaultAddress + chainId + blockNumber + blockTimestamp + logIndex + transactionHash + transactionFrom + owner + assets + shares + } + } +` + +const V2_DEPOSITS_BY_TX_HASHES_QUERY = ` + query GetV2DepositsByTransactionHashes($chainId: Int!, $transactionHashes: [String!]!, $limit: Int!, $offset: Int!, $maxTimestamp: Int) { + V2Deposit(where: { chainId: { _eq: $chainId }, transactionHash: { _in: $transactionHashes }, blockTimestamp: { _lte: $maxTimestamp } }, order_by: [{ blockTimestamp: asc }, { blockNumber: asc }, { logIndex: asc }], limit: $limit, offset: $offset) { + id + vaultAddress + chainId + blockNumber + blockTimestamp + logIndex + transactionHash + transactionFrom + recipient + amount + shares + } + } +` + +const V2_WITHDRAWALS_BY_TX_HASHES_QUERY = ` + query GetV2WithdrawalsByTransactionHashes($chainId: Int!, $transactionHashes: [String!]!, $limit: Int!, $offset: Int!, $maxTimestamp: Int) { + V2Withdraw(where: { chainId: { _eq: $chainId }, transactionHash: { _in: $transactionHashes }, blockTimestamp: { _lte: $maxTimestamp } }, order_by: [{ blockTimestamp: asc }, { blockNumber: asc }, { logIndex: asc }], limit: $limit, offset: $offset) { + id + vaultAddress + chainId + blockNumber + blockTimestamp + logIndex + transactionHash + transactionFrom + recipient + amount + shares + } + } +` + +const TRANSFERS_BY_TX_HASHES_QUERY = ` + query GetTransfersByTransactionHashes($chainId: Int!, $transactionHashes: [String!]!, $limit: Int!, $offset: Int!, $maxTimestamp: Int) { + Transfer(where: { chainId: { _eq: $chainId }, transactionHash: { _in: $transactionHashes }, blockTimestamp: { _lte: $maxTimestamp } }, order_by: [{ blockTimestamp: asc }, { blockNumber: asc }, { logIndex: asc }], limit: $limit, offset: $offset) { + id + vaultAddress + chainId + blockNumber + blockTimestamp + logIndex + transactionHash + transactionFrom + sender + receiver + value + } + } +` + +const USER_EVENT_COUNTS_AGGREGATE_QUERY = ` + query GetUserEventCountsAggregate($address: String!, $maxTimestamp: Int!) { + deposits: Deposit_aggregate(where: { owner: { _eq: $address }, blockTimestamp: { _lte: $maxTimestamp } }) { + aggregate { + count + } + } + withdrawals: Withdraw_aggregate(where: { owner: { _eq: $address }, blockTimestamp: { _lte: $maxTimestamp } }) { + aggregate { + count + } + } + transfersIn: Transfer_aggregate(where: { receiver: { _eq: $address }, blockTimestamp: { _lte: $maxTimestamp } }) { + aggregate { + count + } + } + transfersOut: Transfer_aggregate(where: { sender: { _eq: $address }, blockTimestamp: { _lte: $maxTimestamp } }) { + aggregate { + count + } + } + v2Deposits: V2Deposit_aggregate(where: { recipient: { _eq: $address }, blockTimestamp: { _lte: $maxTimestamp } }) { + aggregate { + count + } + } + v2Withdrawals: V2Withdraw_aggregate(where: { recipient: { _eq: $address }, blockTimestamp: { _lte: $maxTimestamp } }) { + aggregate { + count + } + } + } +` + +interface UserCounts { + depositCount: number + withdrawCount: number + transferInCount: number + transferOutCount: number + v2DepositCount: number + v2WithdrawCount: number +} + +interface AggregateCountField { + aggregate: { + count: number + } | null +} + +interface UserCountsAggregateQuery { + deposits: AggregateCountField | null + withdrawals: AggregateCountField | null + transfersIn: AggregateCountField | null + transfersOut: AggregateCountField | null + v2Deposits: AggregateCountField | null + v2Withdrawals: AggregateCountField | null +} + +async function executeQuery(query: string, variables: Record): Promise { + const headers: Record = { + 'Content-Type': 'application/json' + } + + // Only add admin secret if explicitly configured (not the default 'testing' value) + const password = holdingsConfig.envioPassword + if (password && password !== 'testing') { + headers['x-hasura-admin-secret'] = password + } + + const response = await fetch(holdingsConfig.envioGraphqlUrl, { + method: 'POST', + headers, + body: JSON.stringify({ query, variables }) + }) + + if (!response.ok) { + throw new Error(`GraphQL request failed: ${response.status}`) + } + + const json = (await response.json()) as { data: T; errors?: unknown[] } + + if (json.errors) { + throw new Error(`GraphQL errors: ${JSON.stringify(json.errors)}`) + } + + return json.data +} + +function getAggregateCount(field: AggregateCountField | null | undefined): number { + return field?.aggregate?.count ?? 0 +} + +function getGraphqlAddress(address: string): string { + return getAddress(address) +} + +async function fetchUserCounts(userAddress: string, maxTimestamp?: number): Promise { + const address = getGraphqlAddress(userAddress) + const addressLower = address.toLowerCase() + const ts = maxTimestamp ?? DEFAULT_MAX_TIMESTAMP + + try { + const data = await executeQuery(USER_EVENT_COUNTS_AGGREGATE_QUERY, { + address, + maxTimestamp: ts + }) + + debugLog('graphql', 'loaded aggregate user event counts', { + address: addressLower, + maxTimestamp: ts + }) + return { + depositCount: getAggregateCount(data.deposits), + withdrawCount: getAggregateCount(data.withdrawals), + transferInCount: getAggregateCount(data.transfersIn), + transferOutCount: getAggregateCount(data.transfersOut), + v2DepositCount: getAggregateCount(data.v2Deposits), + v2WithdrawCount: getAggregateCount(data.v2Withdrawals) + } + } catch (error) { + debugError( + 'graphql', + 'aggregate user event counts fetch failed, falling back to sequential event pagination', + error, + { + address: addressLower, + maxTimestamp: ts + } + ) + return null + } +} + +// Default maxTimestamp: 10 years from now (queries require a value, can't be null) +// Using a smaller value to avoid integer overflow in GraphQL +const DEFAULT_MAX_TIMESTAMP = 2000000000 // ~year 2033, safe 32-bit integer +const TX_HASH_BATCH_SIZE = 200 +const TX_HASH_QUERY_CONCURRENCY = 5 + +// Sequential pagination - fetch pages until we get fewer results than BATCH_SIZE +async function fetchAllSequential( + query: string, + variableKey: string, + address: string, + resultKey: string, + maxTimestamp?: number +): Promise { + const allResults: T[] = [] + let offset = 0 + const ts = maxTimestamp ?? DEFAULT_MAX_TIMESTAMP + let pages = 0 + + while (true) { + const variables: Record = { [variableKey]: address, limit: BATCH_SIZE, offset, maxTimestamp: ts } + let data: Record + const pageNumber = pages + 1 + const startedAt = Date.now() + + try { + data = await executeQuery>(query, variables) + } catch (error) { + debugError('graphql', 'sequential event fetch failed', error, { + resultKey, + variableKey, + address, + offset, + maxTimestamp: ts + }) + throw error + } + const batch = data[resultKey] || [] + pages += 1 + const durationMs = Date.now() - startedAt + + debugLog('graphql', 'fetched sequential event page', { + resultKey, + variableKey, + address, + page: pageNumber, + offset, + limit: BATCH_SIZE, + batchCount: batch.length, + durationMs, + maxTimestamp: ts + }) + + allResults.push(...batch) + + if (batch.length < BATCH_SIZE) { + break + } + + offset += BATCH_SIZE + } + + debugLog('graphql', 'fetched sequential event set', { + resultKey, + variableKey, + address, + count: allResults.length, + pages, + maxTimestamp: ts + }) + return allResults +} + +function chunkArray(items: T[], chunkSize: number): T[][] { + const chunks: T[][] = [] + + for (let index = 0; index < items.length; index += chunkSize) { + chunks.push(items.slice(index, index + chunkSize)) + } + + return chunks +} + +function dedupeById(events: T[]): T[] { + return Array.from( + events + .reduce>((deduped, event) => { + if (!deduped.has(event.id)) { + deduped.set(event.id, event) + } + + return deduped + }, new Map()) + .values() + ) +} + +async function fetchTransactionHashBatch( + query: string, + chainId: number, + transactionHashes: string[], + resultKey: string, + maxTimestamp?: number +): Promise { + const allResults: T[] = [] + let offset = 0 + const ts = maxTimestamp ?? DEFAULT_MAX_TIMESTAMP + + while (true) { + const variables = { + chainId, + transactionHashes, + limit: BATCH_SIZE, + offset, + maxTimestamp: ts + } + let data: Record + + try { + data = await executeQuery>(query, variables) + } catch (error) { + debugError('graphql', 'transaction hash event fetch failed', error, { + resultKey, + chainId, + transactionHashCount: transactionHashes.length, + offset, + maxTimestamp: ts + }) + throw error + } + + const batch = data[resultKey] || [] + allResults.push(...batch) + + if (batch.length < BATCH_SIZE) { + break + } + + offset += BATCH_SIZE + } + + return allResults +} + +async function fetchAllByTransactionHashes( + query: string, + transactionHashesByChain: Map, + resultKey: string, + maxTimestamp?: number +): Promise { + const batchSpecs = Array.from(transactionHashesByChain.entries()).flatMap(([chainId, transactionHashes]) => + chunkArray(transactionHashes, TX_HASH_BATCH_SIZE).map((txHashBatch) => ({ + chainId, + transactionHashes: txHashBatch + })) + ) + + if (batchSpecs.length === 0) { + debugLog('graphql', 'skipping transaction hash event fetch because there are no address tx hashes', { + resultKey + }) + return [] + } + + const allResults: T[] = [] + + for (let index = 0; index < batchSpecs.length; index += TX_HASH_QUERY_CONCURRENCY) { + const batchGroup = batchSpecs.slice(index, index + TX_HASH_QUERY_CONCURRENCY) + const groupResults = await Promise.all( + batchGroup.map(({ chainId, transactionHashes }) => + fetchTransactionHashBatch(query, chainId, transactionHashes, resultKey, maxTimestamp) + ) + ) + + groupResults.forEach((results) => { + allResults.push(...results) + }) + } + + debugLog('graphql', 'fetched transaction hash event set', { + resultKey, + chains: transactionHashesByChain.size, + transactionHashes: Array.from(transactionHashesByChain.values()).reduce( + (total, hashes) => total + hashes.length, + 0 + ), + batches: batchSpecs.length, + count: allResults.length, + maxTimestamp: maxTimestamp ?? DEFAULT_MAX_TIMESTAMP + }) + + return allResults +} + +// Parallel batch fetching using aggregate event counts from GraphQL +async function fetchAllParallel( + query: string, + variableKey: string, + address: string, + resultKey: string, + count: number, + maxTimestamp?: number +): Promise { + if (count === 0) { + debugLog('graphql', 'skipping parallel event fetch because expected count is zero', { + resultKey, + variableKey, + address + }) + return [] + } + + const ts = maxTimestamp ?? DEFAULT_MAX_TIMESTAMP + const batchCount = Math.ceil(count / BATCH_SIZE) + const offsets = Array.from({ length: batchCount }, (_, i) => i * BATCH_SIZE) + + const batchResults = await Promise.all( + offsets.map(async (offset, index) => { + const variables: Record = { [variableKey]: address, limit: BATCH_SIZE, offset, maxTimestamp: ts } + const startedAt = Date.now() + + try { + const data = await executeQuery>(query, variables) + const batch = data[resultKey] || [] + const durationMs = Date.now() - startedAt + + debugLog('graphql', 'fetched parallel event page', { + resultKey, + variableKey, + address, + page: index + 1, + offset, + limit: BATCH_SIZE, + batchCount: batch.length, + durationMs, + maxTimestamp: ts + }) + + return batch + } catch (error) { + debugError('graphql', 'parallel event fetch failed', error, { + resultKey, + variableKey, + address, + offset, + maxTimestamp: ts + }) + throw error + } + }) + ) + + const results = batchResults.flat() + debugLog('graphql', 'fetched parallel event set', { + resultKey, + variableKey, + address, + count: results.length, + batches: batchCount, + maxTimestamp: ts + }) + return results +} + +function normalizeV2Deposit(v2: V2DepositEvent): DepositEvent { + return { + id: v2.id, + vaultAddress: v2.vaultAddress, + chainId: v2.chainId, + blockNumber: v2.blockNumber, + blockTimestamp: v2.blockTimestamp, + logIndex: v2.logIndex, + transactionHash: v2.transactionHash, + transactionFrom: v2.transactionFrom, + owner: v2.recipient, + sender: v2.recipient, + assets: v2.amount, + shares: v2.shares + } +} + +function normalizeV2Withdraw(v2: V2WithdrawEvent): WithdrawEvent { + return { + id: v2.id, + vaultAddress: v2.vaultAddress, + chainId: v2.chainId, + blockNumber: v2.blockNumber, + blockTimestamp: v2.blockTimestamp, + logIndex: v2.logIndex, + transactionHash: v2.transactionHash, + transactionFrom: v2.transactionFrom, + owner: v2.recipient, + assets: v2.amount, + shares: v2.shares + } +} + +export type VaultVersion = 'v2' | 'v3' | 'all' +export type HoldingsEventFetchType = 'seq' | 'parallel' +export type HoldingsEventPaginationMode = 'paged' | 'all' + +type AddressEventFetches = [ + Promise, + Promise, + Promise, + Promise, + Promise, + Promise +] + +type AddressEventResults = [ + DepositEvent[], + WithdrawEvent[], + V2DepositEvent[], + V2WithdrawEvent[], + TransferEvent[], + TransferEvent[] +] + +const inFlightAddressScopedEventFetches = new Map>() + +function sortByBlock(events: T[]): T[] { + return [...events].sort( + (a, b) => a.blockTimestamp - b.blockTimestamp || a.blockNumber - b.blockNumber || a.logIndex - b.logIndex + ) +} + +function sortByBlockDesc( + events: T[] +): T[] { + return [...events].sort( + (a, b) => b.blockTimestamp - a.blockTimestamp || b.blockNumber - a.blockNumber || b.logIndex - a.logIndex + ) +} + +function getDepositsByVersion( + v3Deposits: DepositEvent[], + v2DepositsRaw: V2DepositEvent[], + version: VaultVersion +): DepositEvent[] { + const v2Deposits = v2DepositsRaw.map(normalizeV2Deposit) + + return version === 'v3' ? v3Deposits : version === 'v2' ? v2Deposits : sortByBlock([...v3Deposits, ...v2Deposits]) +} + +function getWithdrawalsByVersion( + v3Withdrawals: WithdrawEvent[], + v2WithdrawalsRaw: V2WithdrawEvent[], + version: VaultVersion +): WithdrawEvent[] { + const v2Withdrawals = v2WithdrawalsRaw.map(normalizeV2Withdraw) + + return version === 'v3' + ? v3Withdrawals + : version === 'v2' + ? v2Withdrawals + : sortByBlock([...v3Withdrawals, ...v2Withdrawals]) +} + +function getSequentialAddressEventFetches(addressLower: string, maxTimestamp?: number): AddressEventFetches { + return [ + fetchAllSequential(DEPOSITS_QUERY, 'owner', addressLower, 'Deposit', maxTimestamp), + fetchAllSequential(WITHDRAWALS_QUERY, 'owner', addressLower, 'Withdraw', maxTimestamp), + fetchAllSequential(V2_DEPOSITS_QUERY, 'recipient', addressLower, 'V2Deposit', maxTimestamp), + fetchAllSequential(V2_WITHDRAWALS_QUERY, 'recipient', addressLower, 'V2Withdraw', maxTimestamp), + fetchAllSequential(TRANSFERS_IN_QUERY, 'receiver', addressLower, 'Transfer', maxTimestamp), + fetchAllSequential(TRANSFERS_OUT_QUERY, 'sender', addressLower, 'Transfer', maxTimestamp) + ] +} + +async function fetchAllSingleQuery( + query: string, + variableKey: string, + address: string, + resultKey: string, + maxTimestamp?: number +): Promise { + const ts = maxTimestamp ?? DEFAULT_MAX_TIMESTAMP + const startedAt = Date.now() + const variables: Record = { + [variableKey]: address, + limit: SINGLE_QUERY_LIMIT, + offset: 0, + maxTimestamp: ts + } + + try { + const data = await executeQuery>(query, variables) + const results = data[resultKey] || [] + + debugLog('graphql', 'fetched single-query event set', { + resultKey, + variableKey, + address, + count: results.length, + durationMs: Date.now() - startedAt, + maxTimestamp: ts, + limit: SINGLE_QUERY_LIMIT, + possibleTruncation: results.length === SINGLE_QUERY_LIMIT + }) + + return results + } catch (error) { + debugError('graphql', 'single-query event fetch failed', error, { + resultKey, + variableKey, + address, + maxTimestamp: ts, + limit: SINGLE_QUERY_LIMIT + }) + throw error + } +} + +async function fetchRecentLimited( + query: string, + variableKey: string, + address: string, + resultKey: string, + limit: number, + offset = 0, + maxTimestamp?: number +): Promise { + const ts = maxTimestamp ?? DEFAULT_MAX_TIMESTAMP + const startedAt = Date.now() + const variables: Record = { + [variableKey]: address, + limit, + offset, + maxTimestamp: ts + } + + try { + const data = await executeQuery>(query, variables) + const results = data[resultKey] || [] + + debugLog('graphql', 'fetched recent limited event set', { + resultKey, + variableKey, + address, + count: results.length, + durationMs: Date.now() - startedAt, + maxTimestamp: ts, + limit, + offset + }) + + return results + } catch (error) { + debugError('graphql', 'recent limited event fetch failed', error, { + resultKey, + variableKey, + address, + maxTimestamp: ts, + limit, + offset + }) + throw error + } +} + +function getParallelAddressEventFetches( + addressLower: string, + counts: UserCounts, + maxTimestamp?: number +): AddressEventFetches { + return [ + fetchAllParallel(DEPOSITS_QUERY, 'owner', addressLower, 'Deposit', counts.depositCount, maxTimestamp), + fetchAllParallel( + WITHDRAWALS_QUERY, + 'owner', + addressLower, + 'Withdraw', + counts.withdrawCount, + maxTimestamp + ), + fetchAllParallel( + V2_DEPOSITS_QUERY, + 'recipient', + addressLower, + 'V2Deposit', + counts.v2DepositCount, + maxTimestamp + ), + fetchAllParallel( + V2_WITHDRAWALS_QUERY, + 'recipient', + addressLower, + 'V2Withdraw', + counts.v2WithdrawCount, + maxTimestamp + ), + fetchAllParallel( + TRANSFERS_IN_QUERY, + 'receiver', + addressLower, + 'Transfer', + counts.transferInCount, + maxTimestamp + ), + fetchAllParallel( + TRANSFERS_OUT_QUERY, + 'sender', + addressLower, + 'Transfer', + counts.transferOutCount, + maxTimestamp + ) + ] +} + +function getAddressScopedEventFetchKey( + addressLower: string, + maxTimestamp: number | undefined, + fetchType: HoldingsEventFetchType, + paginationMode: HoldingsEventPaginationMode +): string { + return `${addressLower}:${maxTimestamp ?? DEFAULT_MAX_TIMESTAMP}:${fetchType}:${paginationMode}` +} + +async function fetchAddressScopedEventsUncached( + addressLower: string, + maxTimestamp: number | undefined, + fetchType: HoldingsEventFetchType, + paginationMode: HoldingsEventPaginationMode +): Promise { + if (paginationMode === 'all') { + return Promise.all([ + fetchAllSingleQuery(DEPOSITS_QUERY, 'owner', addressLower, 'Deposit', maxTimestamp), + fetchAllSingleQuery(WITHDRAWALS_QUERY, 'owner', addressLower, 'Withdraw', maxTimestamp), + fetchAllSingleQuery(V2_DEPOSITS_QUERY, 'recipient', addressLower, 'V2Deposit', maxTimestamp), + fetchAllSingleQuery(V2_WITHDRAWALS_QUERY, 'recipient', addressLower, 'V2Withdraw', maxTimestamp), + fetchAllSingleQuery(TRANSFERS_IN_QUERY, 'receiver', addressLower, 'Transfer', maxTimestamp), + fetchAllSingleQuery(TRANSFERS_OUT_QUERY, 'sender', addressLower, 'Transfer', maxTimestamp) + ]) as Promise + } + + if (fetchType === 'seq') { + return Promise.all(getSequentialAddressEventFetches(addressLower, maxTimestamp)) as Promise + } + + const counts = await fetchUserCounts(addressLower, maxTimestamp) + const fetches = + counts === null + ? getSequentialAddressEventFetches(addressLower, maxTimestamp) + : getParallelAddressEventFetches(addressLower, counts, maxTimestamp) + + return Promise.all(fetches) as Promise +} + +async function fetchAddressScopedEvents( + addressLower: string, + maxTimestamp: number | undefined, + fetchType: HoldingsEventFetchType, + paginationMode: HoldingsEventPaginationMode +): Promise { + const key = getAddressScopedEventFetchKey(addressLower, maxTimestamp, fetchType, paginationMode) + const existing = inFlightAddressScopedEventFetches.get(key) + + if (existing) { + debugLog('graphql', 'reusing in-flight address-scoped event fetch', { + address: addressLower, + maxTimestamp: maxTimestamp ?? DEFAULT_MAX_TIMESTAMP, + fetchType, + paginationMode + }) + return existing + } + + const request = fetchAddressScopedEventsUncached(addressLower, maxTimestamp, fetchType, paginationMode).finally( + () => { + inFlightAddressScopedEventFetches.delete(key) + } + ) + + inFlightAddressScopedEventFetches.set(key, request) + return request +} + +// Main export - defaults to sequential paged fetching but allows overrides for experimentation/debugging. +export async function fetchUserEvents( + userAddress: string, + version: VaultVersion = 'all', + maxTimestamp?: number, + fetchType: HoldingsEventFetchType = 'seq', + paginationMode: HoldingsEventPaginationMode = 'paged' +): Promise { + const address = getGraphqlAddress(userAddress) + const addressLower = address.toLowerCase() + + const [v3Deposits, v3Withdrawals, v2DepositsRaw, v2WithdrawalsRaw, transfersIn, transfersOut] = + await fetchAddressScopedEvents(address, maxTimestamp, fetchType, paginationMode) + + const processed = processEvents( + v3Deposits, + v3Withdrawals, + v2DepositsRaw, + v2WithdrawalsRaw, + transfersIn, + transfersOut, + version + ) + debugLog('graphql', 'fetched user events', { + address: addressLower, + version, + fetchType, + paginationMode, + deposits: processed.deposits.length, + withdrawals: processed.withdrawals.length, + transfersIn: processed.transfersIn.length, + transfersOut: processed.transfersOut.length, + maxTimestamp: maxTimestamp ?? null + }) + return processed +} + +export interface RecentAddressActivityEvents { + deposits: DepositEvent[] + withdrawals: WithdrawEvent[] + transfersIn: TransferEvent[] + transfersOut: TransferEvent[] + hasMoreDeposits: boolean + hasMoreWithdrawals: boolean + hasMoreTransfersIn: boolean + hasMoreTransfersOut: boolean +} + +export interface TransactionActivityEvents { + deposits: DepositEvent[] + withdrawals: WithdrawEvent[] + transfers: TransferEvent[] +} + +export async function fetchRecentAddressScopedActivityEvents( + userAddress: string, + version: VaultVersion = 'all', + limitPerSource = 25, + maxTimestamp?: number, + offsetPerSource = 0 +): Promise { + const address = getGraphqlAddress(userAddress) + const addressLower = address.toLowerCase() + const boundedLimit = Math.max(1, limitPerSource) + const boundedOffset = Math.max(0, offsetPerSource) + + const [v3Deposits, v3Withdrawals, v2DepositsRaw, v2WithdrawalsRaw, transfersIn, transfersOut] = await Promise.all([ + fetchRecentLimited( + RECENT_DEPOSITS_QUERY, + 'owner', + address, + 'Deposit', + boundedLimit, + boundedOffset, + maxTimestamp + ), + fetchRecentLimited( + RECENT_WITHDRAWALS_QUERY, + 'owner', + address, + 'Withdraw', + boundedLimit, + boundedOffset, + maxTimestamp + ), + fetchRecentLimited( + RECENT_V2_DEPOSITS_QUERY, + 'recipient', + address, + 'V2Deposit', + boundedLimit, + boundedOffset, + maxTimestamp + ), + fetchRecentLimited( + RECENT_V2_WITHDRAWALS_QUERY, + 'recipient', + address, + 'V2Withdraw', + boundedLimit, + boundedOffset, + maxTimestamp + ), + fetchRecentLimited( + RECENT_TRANSFERS_IN_QUERY, + 'receiver', + address, + 'Transfer', + boundedLimit, + boundedOffset, + maxTimestamp + ), + fetchRecentLimited( + RECENT_TRANSFERS_OUT_QUERY, + 'sender', + address, + 'Transfer', + boundedLimit, + boundedOffset, + maxTimestamp + ) + ]) + + const deposits = sortByBlockDesc(getDepositsByVersion(v3Deposits, v2DepositsRaw, version)) + const withdrawals = sortByBlockDesc(getWithdrawalsByVersion(v3Withdrawals, v2WithdrawalsRaw, version)) + const sortedTransfersIn = sortByBlockDesc(transfersIn) + const sortedTransfersOut = sortByBlockDesc(transfersOut) + const hasMoreDeposits = + version === 'v3' + ? v3Deposits.length === boundedLimit + : version === 'v2' + ? v2DepositsRaw.length === boundedLimit + : v3Deposits.length === boundedLimit || v2DepositsRaw.length === boundedLimit + const hasMoreWithdrawals = + version === 'v3' + ? v3Withdrawals.length === boundedLimit + : version === 'v2' + ? v2WithdrawalsRaw.length === boundedLimit + : v3Withdrawals.length === boundedLimit || v2WithdrawalsRaw.length === boundedLimit + const hasMoreTransfersIn = transfersIn.length === boundedLimit + const hasMoreTransfersOut = transfersOut.length === boundedLimit + + debugLog('graphql', 'fetched recent address-scoped activity events', { + address: addressLower, + version, + limitPerSource: boundedLimit, + deposits: deposits.length, + withdrawals: withdrawals.length, + transfersIn: sortedTransfersIn.length, + transfersOut: sortedTransfersOut.length, + hasMoreDeposits, + hasMoreWithdrawals, + hasMoreTransfersIn, + hasMoreTransfersOut, + offsetPerSource: boundedOffset, + maxTimestamp: maxTimestamp ?? null + }) + + return { + deposits, + withdrawals, + transfersIn: sortedTransfersIn, + transfersOut: sortedTransfersOut, + hasMoreDeposits, + hasMoreWithdrawals, + hasMoreTransfersIn, + hasMoreTransfersOut + } +} + +export async function fetchActivityEventsByTransactionHashes( + transactionHashesByChain: Map, + version: VaultVersion = 'all', + maxTimestamp?: number +): Promise { + const [txHashV3Deposits, txHashV3Withdrawals, txHashV2DepositsRaw, txHashV2WithdrawalsRaw, txHashTransfers] = + await Promise.all([ + fetchAllByTransactionHashes( + DEPOSITS_BY_TX_HASHES_QUERY, + transactionHashesByChain, + 'Deposit', + maxTimestamp + ), + fetchAllByTransactionHashes( + WITHDRAWALS_BY_TX_HASHES_QUERY, + transactionHashesByChain, + 'Withdraw', + maxTimestamp + ), + fetchAllByTransactionHashes( + V2_DEPOSITS_BY_TX_HASHES_QUERY, + transactionHashesByChain, + 'V2Deposit', + maxTimestamp + ), + fetchAllByTransactionHashes( + V2_WITHDRAWALS_BY_TX_HASHES_QUERY, + transactionHashesByChain, + 'V2Withdraw', + maxTimestamp + ), + fetchAllByTransactionHashes( + TRANSFERS_BY_TX_HASHES_QUERY, + transactionHashesByChain, + 'Transfer', + maxTimestamp + ) + ]) + + return { + deposits: sortByBlock(dedupeById([...getDepositsByVersion(txHashV3Deposits, txHashV2DepositsRaw, version)])), + withdrawals: sortByBlock( + dedupeById([...getWithdrawalsByVersion(txHashV3Withdrawals, txHashV2WithdrawalsRaw, version)]) + ), + transfers: sortByBlock(dedupeById(txHashTransfers)) + } +} + +// Shared processing logic for both fetch strategies +function processEvents( + v3Deposits: DepositEvent[], + v3Withdrawals: WithdrawEvent[], + v2DepositsRaw: V2DepositEvent[], + v2WithdrawalsRaw: V2WithdrawEvent[], + transfersIn: TransferEvent[], + transfersOut: TransferEvent[], + version: VaultVersion +): UserEvents { + const v2Deposits = v2DepositsRaw.map(normalizeV2Deposit) + const v2Withdrawals = v2WithdrawalsRaw.map(normalizeV2Withdraw) + + // Build sets of vault addresses by version + const v3VaultAddresses = new Set() + const v2VaultAddresses = new Set() + const transferOnlyVaults = new Set() + + for (const d of v3Deposits) v3VaultAddresses.add(d.vaultAddress.toLowerCase()) + for (const w of v3Withdrawals) v3VaultAddresses.add(w.vaultAddress.toLowerCase()) + for (const d of v2Deposits) v2VaultAddresses.add(d.vaultAddress.toLowerCase()) + for (const w of v2Withdrawals) v2VaultAddresses.add(w.vaultAddress.toLowerCase()) + + // Track vaults that only appear in transfers (no deposit/withdraw events indexed) + // These include vaults where deposit events aren't indexed (e.g., staking vaults) + for (const t of transfersIn) { + const addr = t.vaultAddress.toLowerCase() + if (!v3VaultAddresses.has(addr) && !v2VaultAddresses.has(addr)) { + transferOnlyVaults.add(addr) + } + } + for (const t of transfersOut) { + const addr = t.vaultAddress.toLowerCase() + if (!v3VaultAddresses.has(addr) && !v2VaultAddresses.has(addr)) { + transferOnlyVaults.add(addr) + } + } + + // Filter deposits/withdrawals by version + const deposits = getDepositsByVersion(v3Deposits, v2DepositsRaw, version) + + const withdrawals = getWithdrawalsByVersion(v3Withdrawals, v2WithdrawalsRaw, version) + + // Filter transfers by vault version + // For "all" version, include transfer-only vaults (vaults where user has no deposits/withdrawals but received shares via transfer) + const allowedVaults = + version === 'v3' + ? v3VaultAddresses + : version === 'v2' + ? v2VaultAddresses + : new Set([...v3VaultAddresses, ...v2VaultAddresses, ...transferOnlyVaults]) + + // Filter transfers: + // - For vaults WITH deposit/withdraw events: exclude mints (from zero) and burns (to zero) since they're covered by Deposit/Withdraw events + // - For transfer-only vaults: INCLUDE mints from zero address (these are deposits for vaults where Deposit events aren't indexed) + const filteredTransfersIn = transfersIn.filter((t) => { + const vaultAddr = t.vaultAddress.toLowerCase() + if (!allowedVaults.has(vaultAddr)) return false + + // For transfer-only vaults, include mint events (deposits without Deposit event indexing) + if (transferOnlyVaults.has(vaultAddr)) return true + + // For vaults with deposit events, exclude mints (they're tracked via Deposit events) + return t.sender.toLowerCase() !== '0x0000000000000000000000000000000000000000' + }) + + const filteredTransfersOut = transfersOut.filter((t) => { + const vaultAddr = t.vaultAddress.toLowerCase() + if (!allowedVaults.has(vaultAddr)) return false + + // For transfer-only vaults, include burn events (withdrawals without Withdraw event indexing) + if (transferOnlyVaults.has(vaultAddr)) return true + + // For vaults with withdraw events, exclude burns (they're tracked via Withdraw events) + return t.receiver.toLowerCase() !== '0x0000000000000000000000000000000000000000' + }) + + return { + deposits, + withdrawals, + transfersIn: filteredTransfersIn, + transfersOut: filteredTransfersOut + } +} diff --git a/api/lib/holdings/services/holdings.test.ts b/api/lib/holdings/services/holdings.test.ts new file mode 100644 index 000000000..d62abf299 --- /dev/null +++ b/api/lib/holdings/services/holdings.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest' +import type { DepositEvent, TransferEvent } from '../types' +import { buildPositionTimeline, getShareBalanceAtTimestamp, getUniqueVaults } from './holdings' + +describe('buildPositionTimeline', () => { + it('normalizes staking wrapper events into the underlying vault family', () => { + const deposits: DepositEvent[] = [ + { + id: 'deposit-underlying', + vaultAddress: '0x182863131F9a4630fF9E27830d945B1413e347E8', + chainId: 1, + blockNumber: 10, + blockTimestamp: 100, + logIndex: 0, + transactionHash: '0x1', + transactionFrom: '0xuser', + owner: '0xuser', + sender: '0xuser', + assets: '100', + shares: '100' + } + ] + const transfersIn: TransferEvent[] = [ + { + id: 'transfer-staking', + vaultAddress: '0xd57aea3686d623da2dcebc87010a4f2f38ac7b15', + chainId: 1, + blockNumber: 11, + blockTimestamp: 200, + logIndex: 0, + transactionHash: '0x2', + transactionFrom: '0xuser', + sender: '0xother', + receiver: '0xuser', + value: '50' + } + ] + + const timeline = buildPositionTimeline(deposits, [], transfersIn, []) + + expect(getUniqueVaults(timeline)).toEqual([ + { + chainId: 1, + vaultAddress: '0x182863131f9a4630ff9e27830d945b1413e347e8' + } + ]) + expect(getShareBalanceAtTimestamp(timeline, '0x182863131F9a4630fF9E27830d945B1413e347E8', 1, 300)).toBe(BigInt(150)) + }) +}) diff --git a/api/lib/holdings/services/holdings.ts b/api/lib/holdings/services/holdings.ts new file mode 100644 index 000000000..45764ac51 --- /dev/null +++ b/api/lib/holdings/services/holdings.ts @@ -0,0 +1,151 @@ +import type { DepositEvent, TimelineEvent, TransferEvent, WithdrawEvent } from '../types' +import { getFamilyVaultAddress } from './staking' + +export function buildPositionTimeline( + deposits: DepositEvent[], + withdrawals: WithdrawEvent[], + transfersIn: TransferEvent[], + transfersOut: TransferEvent[] +): TimelineEvent[] { + const events: TimelineEvent[] = [] + + for (const deposit of deposits) { + events.push({ + vaultAddress: getFamilyVaultAddress(deposit.chainId, deposit.vaultAddress), + chainId: deposit.chainId, + blockNumber: deposit.blockNumber, + blockTimestamp: deposit.blockTimestamp, + sharesChange: BigInt(deposit.shares) + }) + } + + for (const withdrawal of withdrawals) { + events.push({ + vaultAddress: getFamilyVaultAddress(withdrawal.chainId, withdrawal.vaultAddress), + chainId: withdrawal.chainId, + blockNumber: withdrawal.blockNumber, + blockTimestamp: withdrawal.blockTimestamp, + sharesChange: -BigInt(withdrawal.shares) + }) + } + + for (const transfer of transfersIn) { + events.push({ + vaultAddress: getFamilyVaultAddress(transfer.chainId, transfer.vaultAddress), + chainId: transfer.chainId, + blockNumber: transfer.blockNumber, + blockTimestamp: transfer.blockTimestamp, + sharesChange: BigInt(transfer.value) + }) + } + + for (const transfer of transfersOut) { + events.push({ + vaultAddress: getFamilyVaultAddress(transfer.chainId, transfer.vaultAddress), + chainId: transfer.chainId, + blockNumber: transfer.blockNumber, + blockTimestamp: transfer.blockTimestamp, + sharesChange: -BigInt(transfer.value) + }) + } + + events.sort((a, b) => { + if (a.blockTimestamp !== b.blockTimestamp) { + return a.blockTimestamp - b.blockTimestamp + } + return a.blockNumber - b.blockNumber + }) + + return events +} + +export function getShareBalanceAtTimestamp( + timeline: TimelineEvent[], + vaultAddress: string, + chainId: number, + timestamp: number +): bigint { + let balance = BigInt(0) + const vaultLower = vaultAddress.toLowerCase() + + for (const event of timeline) { + if (event.blockTimestamp > timestamp) { + break + } + if (event.vaultAddress === vaultLower && event.chainId === chainId) { + balance += event.sharesChange + } + } + + return balance < BigInt(0) ? BigInt(0) : balance +} + +export function getUniqueVaults(timeline: TimelineEvent[]): Array<{ vaultAddress: string; chainId: number }> { + const seen = new Set() + const vaults: Array<{ vaultAddress: string; chainId: number }> = [] + + for (const event of timeline) { + const key = `${event.chainId}:${event.vaultAddress}` + if (!seen.has(key)) { + seen.add(key) + vaults.push({ + vaultAddress: event.vaultAddress, + chainId: event.chainId + }) + } + } + + return vaults +} + +export function generateDailyTimestamps(days: number, endOffsetDays = 0): number[] { + const timestamps: number[] = [] + const now = new Date() + now.setUTCHours(0, 0, 0, 0) + now.setUTCDate(now.getUTCDate() - endOffsetDays) + + for (let i = days - 1; i >= 0; i--) { + const date = new Date(now) + date.setUTCDate(date.getUTCDate() - i) + timestamps.push(Math.floor(date.getTime() / 1000)) + } + + return timestamps +} + +export function generateDailyTimestampsFromRange(startTimestamp: number, endTimestamp: number): number[] { + if (!Number.isFinite(startTimestamp) || !Number.isFinite(endTimestamp)) { + return [] + } + + const startDate = new Date(startTimestamp * 1000) + startDate.setUTCHours(0, 0, 0, 0) + + const endDate = new Date(endTimestamp * 1000) + endDate.setUTCHours(0, 0, 0, 0) + + if (startDate.getTime() > endDate.getTime()) { + return [] + } + + const timestamps: number[] = [] + const cursor = new Date(startDate) + + while (cursor.getTime() <= endDate.getTime()) { + timestamps.push(Math.floor(cursor.getTime() / 1000)) + cursor.setUTCDate(cursor.getUTCDate() + 1) + } + + return timestamps +} + +export function toSettledDayTimestamp(timestamp: number): number { + const date = new Date(timestamp * 1000) + date.setUTCHours(23, 59, 59, 0) + return Math.floor(date.getTime() / 1000) +} + +export function timestampToDateString(timestamp: number): string { + const date = new Date(timestamp * 1000) + return date.toISOString().split('T')[0] +} diff --git a/api/lib/holdings/services/kong.test.ts b/api/lib/holdings/services/kong.test.ts new file mode 100644 index 000000000..b2b3e8c75 --- /dev/null +++ b/api/lib/holdings/services/kong.test.ts @@ -0,0 +1,116 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { fetchMultipleVaultsPPS, getPPS } from './kong' + +function createResponse(points: Array<{ time: number; value: string }>): Response { + return new Response(JSON.stringify(points.map((point) => ({ ...point, component: 'pps' }))), { + status: 200, + headers: { 'content-type': 'application/json' } + }) +} + +describe('getPPS', () => { + it('returns null for an empty timeline instead of defaulting to 1', () => { + expect(getPPS(new Map(), 123)).toBeNull() + }) +}) + +describe('fetchMultipleVaultsPPS', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + it('deduplicates vault requests and retries transient socket resets', async () => { + const fetchFn = vi + .fn() + .mockRejectedValueOnce( + Object.assign(new Error('The socket connection was closed unexpectedly'), { code: 'ECONNRESET' }) + ) + .mockResolvedValue(createResponse([{ time: 100, value: '1.25' }])) as typeof fetch + + const timelines = await fetchMultipleVaultsPPS( + [ + { chainId: 1, vaultAddress: '0xABC' }, + { chainId: 1, vaultAddress: '0xabc' } + ], + { + fetchFn, + maxRetries: 1, + retryDelayMs: 0, + concurrency: 1 + } + ) + + expect(fetchFn).toHaveBeenCalledTimes(2) + expect(timelines.size).toBe(1) + expect(timelines.get('1:0xabc')?.get(100)).toBe(1.25) + }) + + it('retries bun connection refused errors', async () => { + const fetchFn = vi + .fn() + .mockRejectedValueOnce(Object.assign(new Error('Unable to connect'), { code: 'ConnectionRefused' })) + .mockResolvedValue(createResponse([{ time: 100, value: '1.1' }])) as typeof fetch + + const timelines = await fetchMultipleVaultsPPS([{ chainId: 1, vaultAddress: '0xDEF' }], { + fetchFn, + maxRetries: 1, + retryDelayMs: 0, + concurrency: 1 + }) + + expect(fetchFn).toHaveBeenCalledTimes(2) + expect(timelines.get('1:0xdef')?.get(100)).toBe(1.1) + }) + + it('caps concurrent Kong requests', async () => { + const activeRequests = { current: 0, max: 0 } + const fetchFn = vi.fn(async () => { + activeRequests.current += 1 + activeRequests.max = Math.max(activeRequests.max, activeRequests.current) + await new Promise((resolve) => setTimeout(resolve, 10)) + activeRequests.current -= 1 + return createResponse([{ time: 100, value: '1.05' }]) + }) as typeof fetch + + await fetchMultipleVaultsPPS( + [ + { chainId: 1, vaultAddress: '0x1' }, + { chainId: 1, vaultAddress: '0x2' }, + { chainId: 1, vaultAddress: '0x3' }, + { chainId: 1, vaultAddress: '0x4' }, + { chainId: 1, vaultAddress: '0x5' } + ], + { + fetchFn, + concurrency: 2, + maxRetries: 0 + } + ) + + expect(activeRequests.max).toBe(2) + }) + + it('reuses in-flight vault PPS fetches across concurrent callers', async () => { + const fetchFn = vi.fn(async () => { + await new Promise((resolve) => setTimeout(resolve, 10)) + return createResponse([{ time: 100, value: '1.2' }]) + }) as typeof fetch + + const [first, second] = await Promise.all([ + fetchMultipleVaultsPPS([{ chainId: 1, vaultAddress: '0xABC' }], { + fetchFn, + concurrency: 1, + maxRetries: 0 + }), + fetchMultipleVaultsPPS([{ chainId: 1, vaultAddress: '0xabc' }], { + fetchFn, + concurrency: 1, + maxRetries: 0 + }) + ]) + + expect(fetchFn).toHaveBeenCalledTimes(1) + expect(first.get('1:0xabc')?.get(100)).toBe(1.2) + expect(second.get('1:0xabc')?.get(100)).toBe(1.2) + }) +}) diff --git a/api/lib/holdings/services/kong.ts b/api/lib/holdings/services/kong.ts new file mode 100644 index 000000000..cd5780fb5 --- /dev/null +++ b/api/lib/holdings/services/kong.ts @@ -0,0 +1,221 @@ +import { holdingsConfig } from '../config' +import type { KongPPSDataPoint } from '../types' +import { debugError, debugLog } from './debug' + +export type PPSTimeline = Map + +type TFetchLike = typeof fetch + +type TKongFetchOptions = { + fetchFn?: TFetchLike + timeoutMs?: number + concurrency?: number + maxRetries?: number + retryDelayMs?: number +} + +type TKongFetchError = Error & { + code?: string + status?: number +} + +const DEFAULT_TIMEOUT_MS = 4_000 +const DEFAULT_CONCURRENCY = 3 +const DEFAULT_MAX_RETRIES = 2 +const DEFAULT_RETRY_DELAY_MS = 200 +const RETRYABLE_ERROR_CODES = new Set([ + 'ECONNRESET', + 'ECONNREFUSED', + 'ConnectionRefused', + 'ETIMEDOUT', + 'EAI_AGAIN', + 'UND_ERR_SOCKET', + 'UND_ERR_CONNECT_TIMEOUT', + 'UND_ERR_HEADERS_TIMEOUT', + 'UND_ERR_ABORTED' +]) +const RETRYABLE_STATUS_CODES = new Set([408, 425, 429, 500, 502, 503, 504]) +const inFlightVaultPPSFetches = new Map>() + +export function buildPPSTimeline(response: KongPPSDataPoint[]): PPSTimeline { + return new Map(response.map((p) => [p.time, parseFloat(p.value)])) +} + +function wait(delayMs: number): Promise { + return new Promise((resolve) => setTimeout(resolve, delayMs)) +} + +function isRetryableError(error: unknown): boolean { + const kongError = error as Partial + const code = typeof kongError?.code === 'string' ? kongError.code : null + const status = typeof kongError?.status === 'number' ? kongError.status : null + const message = error instanceof Error ? error.message.toLowerCase() : '' + + return ( + (code !== null && RETRYABLE_ERROR_CODES.has(code)) || + (status !== null && RETRYABLE_STATUS_CODES.has(status)) || + message.includes('socket connection was closed unexpectedly') || + message.includes('unable to connect') || + message.includes('timed out') || + message.includes('timeout') + ) +} + +function chunkVaults(items: T[], chunkSize: number): T[][] { + return Array.from({ length: Math.ceil(items.length / chunkSize) }, (_value, index) => + items.slice(index * chunkSize, index * chunkSize + chunkSize) + ) +} + +function getFetchFn(options?: TKongFetchOptions): TFetchLike { + return options?.fetchFn ?? fetch +} + +function getTimeoutMs(options?: TKongFetchOptions): number { + return options?.timeoutMs ?? DEFAULT_TIMEOUT_MS +} + +function getConcurrency(options?: TKongFetchOptions): number { + return options?.concurrency ?? DEFAULT_CONCURRENCY +} + +function getMaxRetries(options?: TKongFetchOptions): number { + return options?.maxRetries ?? DEFAULT_MAX_RETRIES +} + +function getRetryDelayMs(options?: TKongFetchOptions): number { + return options?.retryDelayMs ?? DEFAULT_RETRY_DELAY_MS +} + +export function getPPS(timeline: PPSTimeline, timestamp: number): number | null { + // Exact match + if (timeline.has(timestamp)) { + return timeline.get(timestamp)! + } + + // Find closest timestamp (Kong only has midnight timestamps) + if (timeline.size === 0) { + return null + } + + const timestamps = Array.from(timeline.keys()).sort((a, b) => a - b) + + // If target is before all data, use earliest + if (timestamp < timestamps[0]) { + return timeline.get(timestamps[0])! + } + + // Find the latest timestamp that's <= target (most recent PPS before/at this time) + const closest = timestamps.reduce( + (latest, currentTimestamp) => (currentTimestamp <= timestamp ? currentTimestamp : latest), + timestamps[0] + ) + + return timeline.get(closest) ?? null +} + +export async function fetchVaultPPS( + chainId: number, + vaultAddress: string, + options?: TKongFetchOptions +): Promise { + const url = `${holdingsConfig.kongBaseUrl}/api/rest/timeseries/pps/${chainId}/${vaultAddress}` + const response = await getFetchFn(options)(url, { signal: AbortSignal.timeout(getTimeoutMs(options)) }) + + if (!response.ok) { + const error = new Error(`Kong API request failed: ${response.status} for ${vaultAddress}`) as TKongFetchError + error.status = response.status + throw error + } + + const data = (await response.json()) as KongPPSDataPoint[] + return buildPPSTimeline(data) +} + +async function fetchVaultPPSWithRetry( + chainId: number, + vaultAddress: string, + options?: TKongFetchOptions, + attempt = 0 +): Promise { + try { + return await fetchVaultPPS(chainId, vaultAddress, options) + } catch (error) { + if (attempt >= getMaxRetries(options) || !isRetryableError(error)) { + throw error + } + + debugError('kong-pps', 'retrying vault PPS fetch', error, { + chainId, + vaultAddress, + nextAttempt: attempt + 2 + }) + await wait(getRetryDelayMs(options) * 2 ** attempt) + return fetchVaultPPSWithRetry(chainId, vaultAddress, options, attempt + 1) + } +} + +function fetchVaultPPSDeduped( + chainId: number, + vaultAddress: string, + options?: TKongFetchOptions +): Promise { + const key = `${chainId}:${vaultAddress.toLowerCase()}` + const existing = inFlightVaultPPSFetches.get(key) + + if (existing) { + debugLog('kong-pps', 'reusing in-flight vault PPS fetch', { key }) + return existing + } + + const request = fetchVaultPPSWithRetry(chainId, vaultAddress, options).finally(() => { + inFlightVaultPPSFetches.delete(key) + }) + + inFlightVaultPPSFetches.set(key, request) + return request +} + +export async function fetchMultipleVaultsPPS( + vaults: Array<{ chainId: number; vaultAddress: string }>, + options?: TKongFetchOptions +): Promise> { + const uniqueVaults = Array.from( + new Map(vaults.map((vault) => [`${vault.chainId}:${vault.vaultAddress.toLowerCase()}`, vault])).values() + ) + debugLog('kong-pps', 'fetching PPS timelines', { + requested: vaults.length, + unique: uniqueVaults.length, + concurrency: getConcurrency(options), + maxRetries: getMaxRetries(options) + }) + const results = await chunkVaults(uniqueVaults, getConcurrency(options)).reduce< + Promise> + >(async (allResultsPromise, batch) => { + const allResults = await allResultsPromise + const batchResults = await Promise.all( + batch.map(async ({ chainId, vaultAddress }) => { + const key = `${chainId}:${vaultAddress.toLowerCase()}` + try { + const timeline = await fetchVaultPPSDeduped(chainId, vaultAddress, options) + return { key, timeline } + } catch (error) { + console.error(`[Kong] Failed to fetch PPS for ${key}:`, error) + debugError('kong-pps', 'vault PPS fetch failed', error, { key }) + return { key, timeline: new Map() as PPSTimeline } + } + }) + ) + + return [...allResults, ...batchResults] + }, Promise.resolve([])) + + const map = new Map() + results.map(({ key, timeline }) => map.set(key, timeline)) + debugLog('kong-pps', 'resolved PPS timelines', { + resolved: map.size, + emptyTimelines: Array.from(map.values()).filter((timeline) => timeline.size === 0).length + }) + + return map +} diff --git a/api/lib/holdings/services/nestedVaultPrices.test.ts b/api/lib/holdings/services/nestedVaultPrices.test.ts new file mode 100644 index 000000000..52f0bb33d --- /dev/null +++ b/api/lib/holdings/services/nestedVaultPrices.test.ts @@ -0,0 +1,271 @@ +import { describe, expect, it } from 'vitest' +import type { VaultMetadata } from '../types' +import { + deriveNestedVaultAssetPriceData, + expandNestedVaultAssetPriceRequests, + getNestedVaultPpsIdentifiersFromPriceRequests, + mergeVaultIdentifiers +} from './nestedVaultPrices' +import { toVaultKey } from './pnlShared' + +const INNER_VAULT = '0x696d02db93291651ed510704c9b286841d506987' +const OUTER_VAULT = '0xaaafea48472f77563961cdb53291dedfb46f9040' +const SUPER_VAULT = '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' +const UNDERLYING = '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48' + +const metadata = new Map([ + [ + toVaultKey(1, INNER_VAULT), + { + address: INNER_VAULT, + chainId: 1, + version: 'v3', + category: 'stable', + token: { + address: UNDERLYING, + symbol: 'USDC', + decimals: 6 + }, + decimals: 6 + } + ], + [ + toVaultKey(1, OUTER_VAULT), + { + address: OUTER_VAULT, + chainId: 1, + version: 'v3', + category: 'stable', + token: { + address: INNER_VAULT, + symbol: 'yvUSD', + decimals: 6 + }, + decimals: 6 + } + ], + [ + toVaultKey(1, SUPER_VAULT), + { + address: SUPER_VAULT, + chainId: 1, + version: 'v3', + category: 'stable', + token: { + address: OUTER_VAULT, + symbol: 'Locked yvUSD', + decimals: 6 + }, + decimals: 6 + } + ] +]) + +describe('nested vault asset prices', () => { + it('expands vault-share asset price requests with the inner vault underlying token', () => { + const requests = expandNestedVaultAssetPriceRequests( + [ + { + chainId: 1, + address: INNER_VAULT, + timestamps: [100, 200] + } + ], + metadata + ) + + expect(requests).toEqual([ + { + chainId: 1, + address: INNER_VAULT, + timestamps: [100, 200] + }, + { + chainId: 1, + address: UNDERLYING, + timestamps: [100, 200] + } + ]) + }) + + it('requests PPS for known vault-share assets', () => { + expect( + getNestedVaultPpsIdentifiersFromPriceRequests( + [ + { + chainId: 1, + address: INNER_VAULT, + timestamps: [100] + } + ], + metadata + ) + ).toEqual([{ chainId: 1, vaultAddress: INNER_VAULT }]) + }) + + it('recursively expands multi-level vault-share asset price requests', () => { + const requests = expandNestedVaultAssetPriceRequests( + [ + { + chainId: 1, + address: SUPER_VAULT, + timestamps: [100] + } + ], + metadata + ) + + expect(requests).toEqual([ + { + chainId: 1, + address: SUPER_VAULT, + timestamps: [100] + }, + { + chainId: 1, + address: OUTER_VAULT, + timestamps: [100] + }, + { + chainId: 1, + address: INNER_VAULT, + timestamps: [100] + }, + { + chainId: 1, + address: UNDERLYING, + timestamps: [100] + } + ]) + + expect( + getNestedVaultPpsIdentifiersFromPriceRequests( + [ + { + chainId: 1, + address: SUPER_VAULT, + timestamps: [100] + } + ], + metadata + ) + ).toEqual([ + { chainId: 1, vaultAddress: SUPER_VAULT }, + { chainId: 1, vaultAddress: OUTER_VAULT }, + { chainId: 1, vaultAddress: INNER_VAULT } + ]) + }) + + it('derives missing vault-share token prices from inner PPS and underlying token price', () => { + const priceData = deriveNestedVaultAssetPriceData({ + priceData: new Map([ + [`ethereum:${INNER_VAULT}`, new Map([[100, 1.005]])], + [ + `ethereum:${UNDERLYING}`, + new Map([ + [100, 0.999], + [200, 1.001] + ]) + ] + ]), + priceRequests: [ + { + chainId: 1, + address: INNER_VAULT, + timestamps: [100, 200] + } + ], + vaultMetadata: metadata, + ppsData: new Map([ + [ + toVaultKey(1, INNER_VAULT), + new Map([ + [100, 1.01], + [200, 1.02] + ]) + ] + ]) + }) + + const derivedInnerPrices = priceData.get(`ethereum:${INNER_VAULT}`) + expect(derivedInnerPrices?.get(100)).toBe(1.005) + expect(derivedInnerPrices?.get(200)).toBeCloseTo(1.02102) + }) + + it('recursively derives multi-level vault-share token prices', () => { + const priceData = deriveNestedVaultAssetPriceData({ + priceData: new Map([ + [ + `ethereum:${UNDERLYING}`, + new Map([ + [100, 1], + [200, 1] + ]) + ] + ]), + priceRequests: [ + { + chainId: 1, + address: SUPER_VAULT, + timestamps: [100, 200] + }, + { + chainId: 1, + address: OUTER_VAULT, + timestamps: [100, 200] + }, + { + chainId: 1, + address: INNER_VAULT, + timestamps: [100, 200] + }, + { + chainId: 1, + address: UNDERLYING, + timestamps: [100, 200] + } + ], + vaultMetadata: metadata, + ppsData: new Map([ + [ + toVaultKey(1, INNER_VAULT), + new Map([ + [100, 1.01], + [200, 1.02] + ]) + ], + [ + toVaultKey(1, OUTER_VAULT), + new Map([ + [100, 1.03], + [200, 1.04] + ]) + ], + [ + toVaultKey(1, SUPER_VAULT), + new Map([ + [100, 1.05], + [200, 1.06] + ]) + ] + ]) + }) + + expect(priceData.get(`ethereum:${INNER_VAULT}`)?.get(200)).toBeCloseTo(1.02) + expect(priceData.get(`ethereum:${OUTER_VAULT}`)?.get(200)).toBeCloseTo(1.0608) + expect(priceData.get(`ethereum:${SUPER_VAULT}`)?.get(200)).toBeCloseTo(1.124448) + }) + + it('dedupes PPS identifiers while preserving chain and vault address', () => { + expect( + mergeVaultIdentifiers([ + { chainId: 1, vaultAddress: OUTER_VAULT }, + { chainId: 1, vaultAddress: OUTER_VAULT.toUpperCase() }, + { chainId: 1, vaultAddress: INNER_VAULT } + ]) + ).toEqual([ + { chainId: 1, vaultAddress: OUTER_VAULT }, + { chainId: 1, vaultAddress: INNER_VAULT } + ]) + }) +}) diff --git a/api/lib/holdings/services/nestedVaultPrices.ts b/api/lib/holdings/services/nestedVaultPrices.ts new file mode 100644 index 000000000..e6ae6da96 --- /dev/null +++ b/api/lib/holdings/services/nestedVaultPrices.ts @@ -0,0 +1,259 @@ +import { SUPPORTED_CHAINS, type VaultMetadata } from '../types' +import type { THistoricalPriceRequest } from './defillama' +import { getPPS, type PPSTimeline } from './kong' +import { toVaultKey } from './pnlShared' + +type TPriceRequestDraft = { + chainId: number + address: string + timestamps: Set +} + +type TVaultIdentifier = { + chainId: number + vaultAddress: string +} + +const DEFAULT_MAX_NESTED_VAULT_DEPTH = 4 + +function priceMapKey(chainId: number, tokenAddress: string): string { + return `${getChainPrefix(chainId)}:${tokenAddress.toLowerCase()}` +} + +function getChainPrefix(chainId: number): string { + return SUPPORTED_CHAINS.find((chain) => chain.id === chainId)?.defillamaPrefix || 'ethereum' +} + +function getPriceAtTimestamp(priceMap: Map, targetTimestamp: number): number { + if (priceMap.has(targetTimestamp)) { + return priceMap.get(targetTimestamp)! + } + + const closestPriorTimestamp = Array.from(priceMap.keys()) + .sort((left, right) => left - right) + .filter((timestamp) => timestamp <= targetTimestamp) + .pop() + + return closestPriorTimestamp === undefined ? 0 : priceMap.get(closestPriorTimestamp) || 0 +} + +function priceRequestKey(chainId: number, tokenAddress: string): string { + return `${chainId}:${tokenAddress.toLowerCase()}` +} + +function addPriceRequest(drafts: Map, request: THistoricalPriceRequest): void { + const key = priceRequestKey(request.chainId, request.address) + const draft = drafts.get(key) ?? { + chainId: request.chainId, + address: request.address.toLowerCase(), + timestamps: new Set() + } + + request.timestamps.forEach((timestamp) => { + draft.timestamps.add(timestamp) + }) + drafts.set(key, draft) +} + +function materializePriceRequests(drafts: Map): THistoricalPriceRequest[] { + return Array.from(drafts.values()).map((draft) => ({ + chainId: draft.chainId, + address: draft.address, + timestamps: Array.from(draft.timestamps).sort((a, b) => a - b) + })) +} + +export function mergeVaultIdentifiers(vaults: TVaultIdentifier[]): TVaultIdentifier[] { + return Array.from( + vaults + .reduce>((merged, vault) => { + merged.set(toVaultKey(vault.chainId, vault.vaultAddress), { + chainId: vault.chainId, + vaultAddress: vault.vaultAddress.toLowerCase() + }) + return merged + }, new Map()) + .values() + ) +} + +export function getAssetVaultMetadataLookupIdentifiers(vaultMetadata: Map): TVaultIdentifier[] { + return mergeVaultIdentifiers( + Array.from(vaultMetadata.values()).map((metadata) => ({ + chainId: metadata.chainId, + vaultAddress: metadata.token.address + })) + ) +} + +export async function resolveNestedVaultAssetMetadata( + vaultMetadata: Map, + maxDepth = DEFAULT_MAX_NESTED_VAULT_DEPTH +): Promise> { + if (maxDepth <= 0) { + return vaultMetadata + } + + const missingAssetVaultIdentifiers = getAssetVaultMetadataLookupIdentifiers(vaultMetadata).filter( + (identifier) => !vaultMetadata.has(toVaultKey(identifier.chainId, identifier.vaultAddress)) + ) + + if (missingAssetVaultIdentifiers.length === 0) { + return vaultMetadata + } + + const { fetchMultipleVaultsMetadata } = await import('./vaults') + const assetVaultMetadata = await fetchMultipleVaultsMetadata(missingAssetVaultIdentifiers, { + skipSnapshotFallback: true + }) + const newEntries = Array.from(assetVaultMetadata.entries()).filter(([key]) => !vaultMetadata.has(key)) + + if (newEntries.length === 0) { + return vaultMetadata + } + + return resolveNestedVaultAssetMetadata(new Map([...vaultMetadata, ...newEntries]), maxDepth - 1) +} + +function getNestedVaultPpsIdentifiersForRequest( + request: THistoricalPriceRequest, + vaultMetadata: Map, + maxDepth: number +): TVaultIdentifier[] { + if (maxDepth <= 0) { + return [] + } + + const nestedVault = vaultMetadata.get(toVaultKey(request.chainId, request.address)) + + if (!nestedVault) { + return [] + } + + return [ + { chainId: nestedVault.chainId, vaultAddress: nestedVault.address }, + ...getNestedVaultPpsIdentifiersForRequest( + { + chainId: nestedVault.chainId, + address: nestedVault.token.address, + timestamps: request.timestamps + }, + vaultMetadata, + maxDepth - 1 + ) + ] +} + +export function getNestedVaultPpsIdentifiersFromPriceRequests( + requests: THistoricalPriceRequest[], + vaultMetadata: Map, + maxDepth = DEFAULT_MAX_NESTED_VAULT_DEPTH +): TVaultIdentifier[] { + return mergeVaultIdentifiers( + requests.flatMap((request) => getNestedVaultPpsIdentifiersForRequest(request, vaultMetadata, maxDepth)) + ) +} + +function addNestedVaultAssetPriceRequests( + drafts: Map, + request: THistoricalPriceRequest, + vaultMetadata: Map, + maxDepth: number +): void { + if (maxDepth <= 0) { + return + } + + const nestedVault = vaultMetadata.get(toVaultKey(request.chainId, request.address)) + if (!nestedVault) { + return + } + + const nestedAssetRequest = { + chainId: nestedVault.chainId, + address: nestedVault.token.address, + timestamps: request.timestamps + } + + addPriceRequest(drafts, nestedAssetRequest) + addNestedVaultAssetPriceRequests(drafts, nestedAssetRequest, vaultMetadata, maxDepth - 1) +} + +export function expandNestedVaultAssetPriceRequests( + requests: THistoricalPriceRequest[], + vaultMetadata: Map, + maxDepth = DEFAULT_MAX_NESTED_VAULT_DEPTH +): THistoricalPriceRequest[] { + const drafts = new Map() + + requests.forEach((request) => { + addPriceRequest(drafts, request) + addNestedVaultAssetPriceRequests(drafts, request, vaultMetadata, maxDepth) + }) + + return materializePriceRequests(drafts) +} + +function deriveNestedVaultAssetPriceDataOnce(args: { + priceData: Map> + priceRequests: THistoricalPriceRequest[] + vaultMetadata: Map + ppsData: Map +}): Map> { + const result = new Map(Array.from(args.priceData.entries()).map(([key, priceMap]) => [key, new Map(priceMap)])) + + args.priceRequests.forEach((request) => { + const nestedVault = args.vaultMetadata.get(toVaultKey(request.chainId, request.address)) + if (!nestedVault) { + return + } + + const ppsMap = args.ppsData.get(toVaultKey(nestedVault.chainId, nestedVault.address)) + const underlyingPriceMap = result.get(priceMapKey(nestedVault.chainId, nestedVault.token.address)) + if (!ppsMap || !underlyingPriceMap) { + return + } + + const targetKey = priceMapKey(request.chainId, request.address) + const targetPriceMap = result.get(targetKey) ?? new Map() + + request.timestamps.forEach((timestamp) => { + if ((targetPriceMap.get(timestamp) ?? 0) > 0) { + return + } + + const pricePerShare = getPPS(ppsMap, timestamp) + const underlyingTokenPrice = getPriceAtTimestamp(underlyingPriceMap, timestamp) + if (pricePerShare === null || pricePerShare <= 0 || underlyingTokenPrice <= 0) { + return + } + + targetPriceMap.set(timestamp, pricePerShare * underlyingTokenPrice) + }) + + result.set(targetKey, targetPriceMap) + }) + + return result +} + +export function deriveNestedVaultAssetPriceData(args: { + priceData: Map> + priceRequests: THistoricalPriceRequest[] + vaultMetadata: Map + ppsData: Map + maxDepth?: number +}): Map> { + const maxDepth = args.maxDepth ?? DEFAULT_MAX_NESTED_VAULT_DEPTH + + return Array.from({ length: Math.max(1, maxDepth) }).reduce>>( + (priceData) => + deriveNestedVaultAssetPriceDataOnce({ + priceData, + priceRequests: args.priceRequests, + vaultMetadata: args.vaultMetadata, + ppsData: args.ppsData + }), + args.priceData + ) +} diff --git a/api/lib/holdings/services/pnlEvents.ts b/api/lib/holdings/services/pnlEvents.ts new file mode 100644 index 000000000..48069e366 --- /dev/null +++ b/api/lib/holdings/services/pnlEvents.ts @@ -0,0 +1,143 @@ +import type { DepositEvent, TransferEvent, UserEvents, WithdrawEvent } from '../types' +import type { TransactionActivityEvents } from './graphql' +import { lowerCaseAddress, toVaultKey } from './pnlShared' +import type { TRawPnlEvent, TRawScopes } from './pnlTypes' +import { getFamilyVaultAddress, isStakingVault } from './staking' + +function compareRawEvents(a: TRawPnlEvent, b: TRawPnlEvent): number { + return ( + a.blockTimestamp - b.blockTimestamp || + a.blockNumber - b.blockNumber || + a.logIndex - b.logIndex || + a.id.localeCompare(b.id) + ) +} + +function normalizeDeposit(event: DepositEvent): Omit, 'scopes'> { + return { + kind: 'deposit', + id: event.id, + chainId: event.chainId, + vaultAddress: lowerCaseAddress(event.vaultAddress), + familyVaultAddress: getFamilyVaultAddress(event.chainId, event.vaultAddress), + isStakingVault: isStakingVault(event.chainId, event.vaultAddress), + blockNumber: event.blockNumber, + blockTimestamp: event.blockTimestamp, + logIndex: event.logIndex, + transactionHash: event.transactionHash, + transactionFrom: lowerCaseAddress(event.transactionFrom), + owner: lowerCaseAddress(event.owner), + sender: lowerCaseAddress(event.sender), + shares: BigInt(event.shares), + assets: BigInt(event.assets) + } +} + +function normalizeWithdrawal(event: WithdrawEvent): Omit, 'scopes'> { + return { + kind: 'withdrawal', + id: event.id, + chainId: event.chainId, + vaultAddress: lowerCaseAddress(event.vaultAddress), + familyVaultAddress: getFamilyVaultAddress(event.chainId, event.vaultAddress), + isStakingVault: isStakingVault(event.chainId, event.vaultAddress), + blockNumber: event.blockNumber, + blockTimestamp: event.blockTimestamp, + logIndex: event.logIndex, + transactionHash: event.transactionHash, + transactionFrom: lowerCaseAddress(event.transactionFrom), + owner: lowerCaseAddress(event.owner), + shares: BigInt(event.shares), + assets: BigInt(event.assets) + } +} + +function normalizeTransfer(event: TransferEvent): Omit, 'scopes'> { + return { + kind: 'transfer', + id: event.id, + chainId: event.chainId, + vaultAddress: lowerCaseAddress(event.vaultAddress), + familyVaultAddress: getFamilyVaultAddress(event.chainId, event.vaultAddress), + isStakingVault: isStakingVault(event.chainId, event.vaultAddress), + blockNumber: event.blockNumber, + blockTimestamp: event.blockTimestamp, + logIndex: event.logIndex, + transactionHash: event.transactionHash, + transactionFrom: lowerCaseAddress(event.transactionFrom), + sender: lowerCaseAddress(event.sender), + receiver: lowerCaseAddress(event.receiver), + shares: BigInt(event.value) + } +} + +function mergeRawEvent( + merged: Map, + event: Omit, + scope: keyof TRawScopes +): void { + const eventKey = `${event.kind}:${event.id}` + const existing = merged.get(eventKey) + + if (existing) { + existing.scopes[scope] = true + return + } + + merged.set(eventKey, { + ...event, + scopes: { + address: scope === 'address', + tx: scope === 'tx' + } + } as TRawPnlEvent) +} + +export function buildAddressScopedRawPnlEvents(addressEvents: UserEvents): TRawPnlEvent[] { + const merged = new Map() + const eventSources: Array<{ events: Array>; scope: keyof TRawScopes }> = [ + { events: addressEvents.deposits.map(normalizeDeposit), scope: 'address' }, + { events: addressEvents.withdrawals.map(normalizeWithdrawal), scope: 'address' }, + { events: addressEvents.transfersIn.map(normalizeTransfer), scope: 'address' }, + { events: addressEvents.transfersOut.map(normalizeTransfer), scope: 'address' } + ] + + eventSources.forEach(({ events, scope }) => { + events.forEach((event) => { + mergeRawEvent(merged, event, scope) + }) + }) + + return Array.from(merged.values()).sort(compareRawEvents) +} + +export function mergeAddressScopedRawPnlEventsWithTransactionActivity( + addressEvents: TRawPnlEvent[], + transactionEvents: TransactionActivityEvents, + allowedFamilyKeys?: Set +): TRawPnlEvent[] { + const merged = new Map() + + addressEvents.forEach((event) => { + merged.set(`${event.kind}:${event.id}`, { + ...event, + scopes: { ...event.scopes } + }) + }) + + const txEventSources: Array> = [ + ...transactionEvents.deposits.map(normalizeDeposit), + ...transactionEvents.withdrawals.map(normalizeWithdrawal), + ...transactionEvents.transfers.map(normalizeTransfer) + ] + + txEventSources + .filter((event) => + allowedFamilyKeys ? allowedFamilyKeys.has(toVaultKey(event.chainId, event.familyVaultAddress)) : true + ) + .forEach((event) => { + mergeRawEvent(merged, event, 'tx') + }) + + return Array.from(merged.values()).sort(compareRawEvents) +} diff --git a/api/lib/holdings/services/pnlShared.ts b/api/lib/holdings/services/pnlShared.ts new file mode 100644 index 000000000..fb9fe289b --- /dev/null +++ b/api/lib/holdings/services/pnlShared.ts @@ -0,0 +1,80 @@ +import { formatUnits } from 'viem' +import type { TLot } from './pnlTypes' + +export const ZERO = 0n +export const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000' +export const KNOWN_VAULT_ROLLOVER_INTERMEDIARIES = new Set([ + '0x9327e2fdc57c7d70782f29ab46f6385afaf4503c', + '0x1824df8d751704fa10fa371d62a37f9b8772ab90', + '0x1112dbcf805682e828606f74ab717abf4b4fd8de', + '0x4fe93ebc4ce6ae4f81601cc7ce7139023919e003' +]) +export const KNOWN_ZERO_BASIS_REWARD_DISTRIBUTIONS = new Set([ + '1:0xb226c52eb411326cdb54824a88abafdaaff16d3d:0xbf319ddc2edc1eb6fdf9910e39b37be221c8805f', + '747474:0xa03e39cdeac8c2823a6edc80956207294807c20d:0x80c34bd3a3569e126e7055831036aa7b212cb159', + '747474:0x67c912ff560951526bffdff66dfbd4df8ae23756:0x80c34bd3a3569e126e7055831036aa7b212cb159', + '747474:0x67c912ff560951526bffdff66dfbd4df8ae23756:0xe007ca01894c863d7898045ed5a3b4abf0b18f37', + '747474:0x67c912ff560951526bffdff66dfbd4df8ae23756:0xaa0362ecc584b985056e47812931270b99c91f9d', + '747474:0x67c912ff560951526bffdff66dfbd4df8ae23756:0x9a6bd7b6fd5c4f87eb66356441502fc7dcdd185b', + '747474:0x5480f3152748809495bd56c14eab4a622aa3a19b:0x80c34bd3a3569e126e7055831036aa7b212cb159', + '747474:0x5480f3152748809495bd56c14eab4a622aa3a19b:0xe007ca01894c863d7898045ed5a3b4abf0b18f37', + '747474:0x5480f3152748809495bd56c14eab4a622aa3a19b:0xaa0362ecc584b985056e47812931270b99c91f9d', + '747474:0x5480f3152748809495bd56c14eab4a622aa3a19b:0x9a6bd7b6fd5c4f87eb66356441502fc7dcdd185b' +]) +export const KNOWN_COMPATIBLE_ASSET_VAULT_ROLLOVERS = new Set([ + '1:0x23346b04a7f55b8760e5860aa5a77383d63491cd:0x9f4330700a36b29952869fac9b33f45eedd8a3d8' +]) + +export function lowerCaseAddress(address: string): string { + return address.toLowerCase() +} + +export function toVaultKey(chainId: number, vaultAddress: string): string { + return `${chainId}:${vaultAddress.toLowerCase()}` +} + +export function isKnownZeroBasisRewardDistribution( + chainId: number, + distributorAddress: string, + rewardedTokenAddress: string +): boolean { + return KNOWN_ZERO_BASIS_REWARD_DISTRIBUTIONS.has( + `${chainId}:${distributorAddress.toLowerCase()}:${rewardedTokenAddress.toLowerCase()}` + ) +} + +export function isKnownCompatibleAssetVaultRollover( + chainId: number, + outerVaultAddress: string, + innerVaultAddress: string +): boolean { + return KNOWN_COMPATIBLE_ASSET_VAULT_ROLLOVERS.has( + `${chainId}:${outerVaultAddress.toLowerCase()}:${innerVaultAddress.toLowerCase()}` + ) +} + +export function minBigInt(a: bigint, b: bigint): bigint { + return a < b ? a : b +} + +export function positiveBigInt(value: bigint): bigint { + return value > ZERO ? value : ZERO +} + +export function negativeBigIntMagnitude(value: bigint): bigint { + return value < ZERO ? -value : ZERO +} + +export function formatAmount(value: bigint, decimals: number): number { + const absoluteValue = value < ZERO ? -value : value + const sign = value < ZERO ? -1 : 1 + return sign * parseFloat(formatUnits(absoluteValue, decimals)) +} + +export function sumShares(lots: TLot[]): bigint { + return lots.reduce((total, lot) => total + lot.shares, ZERO) +} + +export function sumKnownCostBasis(lots: TLot[]): bigint { + return lots.reduce((total, lot) => total + (lot.costBasis ?? ZERO), ZERO) +} diff --git a/api/lib/holdings/services/pnlSimple.test.ts b/api/lib/holdings/services/pnlSimple.test.ts new file mode 100644 index 000000000..ea4a0b71c --- /dev/null +++ b/api/lib/holdings/services/pnlSimple.test.ts @@ -0,0 +1,2066 @@ +import { describe, expect, it } from 'vitest' +import type { VaultMetadata } from '../types' +import type { TransactionActivityEvents } from './graphql' +import { mergeAddressScopedRawPnlEventsWithTransactionActivity } from './pnlEvents' +import { toVaultKey } from './pnlShared' +import { + buildProtocolReturnFamilyHistorySeries, + buildProtocolReturnHistorySeries, + buildProtocolReturnLedgers, + buildReceiptPriceRequests, + type HoldingsPnLSimpleVault, + materializeProtocolReturnVaults +} from './pnlSimple' +import type { TRawPnlEvent } from './pnlTypes' + +const USER = '0x1111111111111111111111111111111111111111' +const OTHER = '0x2222222222222222222222222222222222222222' +const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000' +const VAULT = '0x3333333333333333333333333333333333333333' +const ASSET = '0x4444444444444444444444444444444444444444' +const VAULT_KEY = toVaultKey(1, VAULT) +const ASSET_PRICE_KEY = `ethereum:${ASSET}` +const ONE = 10n ** 18n +const YVUSD_UNDERLYING = '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48' +const YVUSD_UNLOCKED = '0x696d02db93291651ed510704c9b286841d506987' +const YVUSD_LOCKED = '0xaaafea48472f77563961cdb53291dedfb46f9040' +const YVUSD_UNLOCKED_KEY = toVaultKey(1, YVUSD_UNLOCKED) +const YVUSD_LOCKED_KEY = toVaultKey(1, YVUSD_LOCKED) +const YVUSD_ONE = 10n ** 6n + +const metadata = new Map([ + [ + VAULT_KEY, + { + address: VAULT, + chainId: 1, + version: 'v3', + category: 'stable', + token: { + address: ASSET, + symbol: 'TST', + decimals: 18 + }, + decimals: 18 + } + ] +]) + +function baseEvent(overrides: Partial): TRawPnlEvent { + const id = overrides.id ?? 'event' + return { + kind: 'transfer', + id, + chainId: 1, + vaultAddress: VAULT, + familyVaultAddress: VAULT, + isStakingVault: false, + blockNumber: 1, + blockTimestamp: 100, + logIndex: 0, + transactionHash: `0x${id}`, + transactionFrom: USER, + sender: OTHER, + receiver: USER, + shares: 100n * ONE, + scopes: { + address: true, + tx: false + }, + ...overrides + } as TRawPnlEvent +} + +function materializeVault(args: { + events: TRawPnlEvent[] + ppsData: Map> + priceData: Map> + currentTimestamp?: number +}): HoldingsPnLSimpleVault { + const currentTimestamp = args.currentTimestamp ?? 300 + const ledgers = buildProtocolReturnLedgers({ + events: args.events, + userAddress: USER, + metadata, + ppsData: args.ppsData, + priceData: args.priceData, + currentTimestamp + }) + return materializeProtocolReturnVaults({ + ledgers, + metadata, + ppsData: args.ppsData, + currentTimestamp + })[0]! +} + +describe('pnl simple protocol return', () => { + it('deduplicates receipt price fetches by underlying token and day bucket', () => { + const requests = buildReceiptPriceRequests({ + events: [ + baseEvent({ + kind: 'deposit', + id: 'deposit', + blockTimestamp: 100, + shares: 100n * ONE, + assets: 100n * ONE, + owner: USER, + sender: USER + }), + baseEvent({ + kind: 'transfer', + id: 'transfer-in', + blockTimestamp: 200, + logIndex: 1, + sender: OTHER, + receiver: USER + }), + baseEvent({ + kind: 'transfer', + id: 'transfer-out', + blockTimestamp: 300, + logIndex: 2, + sender: USER, + receiver: OTHER + }) + ], + metadata, + userAddress: USER, + currentTimestamp: 172800 + }) + + expect(requests).toEqual([ + { + chainId: 1, + address: ASSET, + timestamps: [0, 86400] + } + ]) + }) + + it('measures open deposit growth using receipt-time price as weight', () => { + const vault = materializeVault({ + events: [ + baseEvent({ + kind: 'deposit', + id: 'deposit', + shares: 100n * ONE, + assets: 100n * ONE, + owner: USER, + sender: USER + }) + ], + ppsData: new Map([[VAULT_KEY, new Map([[300, 1.1]])]]), + priceData: new Map([[ASSET_PRICE_KEY, new Map([[100, 2]])]]) + }) + + expect(vault.status).toBe('ok') + expect(vault.baselineUnderlying).toBe(100) + expect(vault.growthUnderlying).toBeCloseTo(10) + expect(vault.baselineWeightUsd).toBe(200) + expect(vault.growthWeightUsd).toBeCloseTo(20) + expect(vault.protocolReturnPct).toBeCloseTo(10) + expect(vault.baselineExposureUnderlyingYears).toBeCloseTo(100 * (200 / (365 * 24 * 60 * 60))) + expect(vault.annualizedProtocolReturnPct).toBeCloseTo((20 / (200 * (200 / (365 * 24 * 60 * 60)))) * 100) + }) + + it('measures realized withdrawal growth without current asset repricing', () => { + const vault = materializeVault({ + events: [ + baseEvent({ + kind: 'deposit', + id: 'deposit', + shares: 100n * ONE, + assets: 100n * ONE, + owner: USER, + sender: USER + }), + baseEvent({ + kind: 'withdrawal', + id: 'withdrawal', + blockTimestamp: 200, + logIndex: 1, + shares: 100n * ONE, + assets: 110n * ONE, + owner: USER + }) + ], + ppsData: new Map([[VAULT_KEY, new Map([[300, 1.1]])]]), + priceData: new Map([[ASSET_PRICE_KEY, new Map([[100, 2]])]]) + }) + + expect(vault.status).toBe('ok') + expect(vault.shares).toBe('0') + expect(vault.realizedGrowthUnderlying).toBe(10) + expect(vault.unrealizedGrowthUnderlying).toBe(0) + expect(vault.growthWeightUsd).toBe(20) + expect(vault.protocolReturnPct).toBe(10) + }) + + it('annualizes using time-weighted baseline exposure for closed lots', () => { + const secondsPerYear = 365 * 24 * 60 * 60 + const currentTimestamp = 100 + secondsPerYear + const vault = materializeVault({ + events: [ + baseEvent({ + kind: 'deposit', + id: 'deposit', + shares: 100n * ONE, + assets: 100n * ONE, + owner: USER, + sender: USER + }), + baseEvent({ + kind: 'withdrawal', + id: 'withdrawal', + blockTimestamp: currentTimestamp, + logIndex: 1, + shares: 100n * ONE, + assets: 110n * ONE, + owner: USER + }) + ], + ppsData: new Map([[VAULT_KEY, new Map([[currentTimestamp, 1.1]])]]), + priceData: new Map([[ASSET_PRICE_KEY, new Map([[100, 1]])]]), + currentTimestamp + }) + + expect(vault.baselineExposureUnderlyingYears).toBeCloseTo(100) + expect(vault.baselineExposureWeightUsdYears).toBeCloseTo(100) + expect(vault.growthWeightUsd).toBeCloseTo(10) + expect(vault.annualizedProtocolReturnPct).toBeCloseTo(10) + }) + + it('uses PPS for transfer-only receipts and exits', () => { + const vault = materializeVault({ + events: [ + baseEvent({ + kind: 'transfer', + id: 'transfer-in', + blockTimestamp: 100, + shares: 100n * ONE, + sender: OTHER, + receiver: USER + }), + baseEvent({ + kind: 'transfer', + id: 'transfer-out', + blockTimestamp: 200, + logIndex: 1, + shares: 100n * ONE, + sender: USER, + receiver: OTHER + }) + ], + ppsData: new Map([ + [ + VAULT_KEY, + new Map([ + [100, 1], + [200, 1.2], + [300, 1.2] + ]) + ] + ]), + priceData: new Map([[ASSET_PRICE_KEY, new Map([[100, 3]])]]) + }) + + expect(vault.status).toBe('ok') + expect(vault.baselineUnderlying).toBe(100) + expect(vault.realizedGrowthUnderlying).toBeCloseTo(20) + expect(vault.baselineWeightUsd).toBe(300) + expect(vault.growthWeightUsd).toBeCloseTo(60) + expect(vault.protocolReturnPct).toBeCloseTo(20) + }) + + it('values staking wrapper deposit and withdrawal events with family PPS', () => { + const UNDERLYING_VAULT = '0x182863131F9a4630fF9E27830d945B1413e347E8' + const STAKING_VAULT = '0xd57aea3686d623da2dcebc87010a4f2f38ac7b15' + const STAKING_VAULT_KEY = toVaultKey(1, UNDERLYING_VAULT) + const stakingMetadata = new Map([ + [ + STAKING_VAULT_KEY, + { + address: UNDERLYING_VAULT, + chainId: 1, + version: 'v3', + category: 'stable', + token: { + address: ASSET, + symbol: 'TST', + decimals: 18 + }, + decimals: 18 + } + ] + ]) + const ledgers = buildProtocolReturnLedgers({ + events: [ + baseEvent({ + kind: 'deposit', + id: 'staking-deposit', + blockTimestamp: 100, + vaultAddress: STAKING_VAULT, + familyVaultAddress: UNDERLYING_VAULT, + isStakingVault: true, + shares: 100n * ONE, + assets: 100n * ONE, + owner: USER, + sender: USER + }), + baseEvent({ + kind: 'withdrawal', + id: 'staking-withdrawal', + blockTimestamp: 200, + blockNumber: 2, + vaultAddress: STAKING_VAULT, + familyVaultAddress: UNDERLYING_VAULT, + isStakingVault: true, + shares: 100n * ONE, + assets: 100n * ONE, + owner: USER + }) + ], + userAddress: USER, + metadata: stakingMetadata, + ppsData: new Map([ + [ + STAKING_VAULT_KEY, + new Map([ + [100, 1.1], + [200, 1.2], + [300, 1.2] + ]) + ] + ]), + priceData: new Map([[ASSET_PRICE_KEY, new Map([[100, 1]])]]), + currentTimestamp: 300 + }) + const vault = materializeProtocolReturnVaults({ + ledgers, + metadata: stakingMetadata, + ppsData: new Map([ + [ + STAKING_VAULT_KEY, + new Map([ + [100, 1.1], + [200, 1.2], + [300, 1.2] + ]) + ] + ]), + currentTimestamp: 300 + })[0]! + + expect(vault.baselineUnderlying).toBeCloseTo(110) + expect(vault.realizedGrowthUnderlying).toBeCloseTo(10) + expect(vault.growthWeightUsd).toBeCloseTo(10) + expect(vault.protocolReturnPct).toBeCloseTo(9.0909090909) + }) + + it('marks staking wrapper receipt valuation partial when family PPS is missing', () => { + const UNDERLYING_VAULT = '0x182863131F9a4630fF9E27830d945B1413e347E8' + const STAKING_VAULT = '0xd57aea3686d623da2dcebc87010a4f2f38ac7b15' + const STAKING_VAULT_KEY = toVaultKey(1, UNDERLYING_VAULT) + const stakingMetadata = new Map([ + [ + STAKING_VAULT_KEY, + { + address: UNDERLYING_VAULT, + chainId: 1, + version: 'v3', + category: 'stable', + token: { + address: ASSET, + symbol: 'TST', + decimals: 18 + }, + decimals: 18 + } + ] + ]) + const ppsData = new Map([[STAKING_VAULT_KEY, new Map()]]) + const ledgers = buildProtocolReturnLedgers({ + events: [ + baseEvent({ + kind: 'deposit', + id: 'staking-deposit', + blockTimestamp: 100, + vaultAddress: STAKING_VAULT, + familyVaultAddress: UNDERLYING_VAULT, + isStakingVault: true, + shares: 100n * ONE, + assets: 100n * ONE, + owner: USER, + sender: USER + }) + ], + userAddress: USER, + metadata: stakingMetadata, + ppsData, + priceData: new Map([[ASSET_PRICE_KEY, new Map([[100, 1]])]]), + currentTimestamp: 300 + }) + const vault = materializeProtocolReturnVaults({ + ledgers, + metadata: stakingMetadata, + ppsData, + currentTimestamp: 300 + })[0]! + + expect(vault.status).toBe('missing_pps') + expect(vault.issues).toContain('missing_pps') + expect(vault.baselineUnderlying).toBeCloseTo(100) + }) + + it('builds a daily growth series without deposit jumps in growth', () => { + const history = buildProtocolReturnHistorySeries({ + events: [ + baseEvent({ + kind: 'deposit', + id: 'deposit-1', + blockTimestamp: 100, + shares: 100n * ONE, + assets: 100n * ONE, + owner: USER, + sender: USER + }), + baseEvent({ + kind: 'deposit', + id: 'deposit-2', + blockTimestamp: 200, + logIndex: 1, + shares: 45454545454545454545n, + assets: 50n * ONE, + owner: USER, + sender: USER + }) + ], + userAddress: USER, + metadata, + ppsData: new Map([ + [ + VAULT_KEY, + new Map([ + [100, 1], + [150, 1.1], + [200, 1.1], + [250, 1.2] + ]) + ] + ]), + priceData: new Map([[ASSET_PRICE_KEY, new Map([[0, 1]])]]), + ethPriceData: new Map([ + [0, 2], + [100, 2], + [150, 2], + [200, 2], + [250, 2] + ]), + timestamps: [100, 150, 200, 250] + }) + + expect(history).toHaveLength(4) + expect(history[0]).toEqual({ + date: '1970-01-01', + timestamp: 100, + growthWeightUsd: 0, + growthWeightEth: 0, + protocolReturnPct: 0, + annualizedProtocolReturnPct: null, + growthIndex: 100 + }) + expect(history[1]?.growthWeightUsd).toBeCloseTo(10) + expect(history[1]?.growthWeightEth).toBeCloseTo(5) + expect(history[1]?.protocolReturnPct).toBeCloseTo(10) + expect(history[1]?.annualizedProtocolReturnPct).not.toBeNull() + expect(history[1]?.growthIndex).toBeCloseTo(110) + expect(history[2]?.growthWeightUsd).toBeCloseTo(10) + expect(history[2]?.growthWeightEth).toBeCloseTo(5) + expect(history[2]?.protocolReturnPct).toBeCloseTo(6.6666666667) + expect(history[2]?.growthIndex).toBeCloseTo(110) + expect(history[3]?.growthWeightUsd).toBeCloseTo(24.5454545455) + expect(history[3]?.growthWeightEth).toBeCloseTo(12.2727272727) + expect(history[3]?.protocolReturnPct).toBeCloseTo(16.3636363636) + expect(history[3]?.growthIndex).toBeCloseTo(120.6666666667) + }) + + it('includes current underlying and growth fields for single-vault history points', () => { + const history = buildProtocolReturnHistorySeries({ + events: [ + baseEvent({ + kind: 'deposit', + id: 'deposit', + blockTimestamp: 100, + shares: 100n * ONE, + assets: 100n * ONE, + owner: USER, + sender: USER + }) + ], + userAddress: USER, + metadata, + ppsData: new Map([ + [ + VAULT_KEY, + new Map([ + [100, 1], + [200, 1.1] + ]) + ] + ]), + priceData: new Map([[ASSET_PRICE_KEY, new Map([[0, 1]])]]), + timestamps: [100, 200], + selectedVaultKey: VAULT_KEY + }) + + expect(history[0]?.currentUnderlying).toBeCloseTo(100) + expect(history[0]?.growthUnderlying).toBeCloseTo(0) + expect(history[0]?.sharesFormatted).toBeCloseTo(100) + expect(history[0]?.pricePerShare).toBeCloseTo(1) + expect(history[1]?.currentUnderlying).toBeCloseTo(110) + expect(history[1]?.growthUnderlying).toBeCloseTo(10) + expect(history[1]?.sharesFormatted).toBeCloseTo(100) + expect(history[1]?.pricePerShare).toBeCloseTo(1.1) + }) + + it('combines current underlying and growth fields for multi-vault history points', () => { + const combinedMetadata = new Map([ + ...metadata, + [ + YVUSD_UNLOCKED_KEY, + { + address: YVUSD_UNLOCKED, + chainId: 1, + version: 'v3', + category: 'stable', + token: { + address: YVUSD_UNDERLYING, + symbol: 'USDC', + decimals: 6 + }, + decimals: 6 + } + ], + [ + YVUSD_LOCKED_KEY, + { + address: YVUSD_LOCKED, + chainId: 1, + version: 'v3', + category: 'stable', + token: { + address: YVUSD_UNLOCKED, + symbol: 'yvUSD', + decimals: 6 + }, + decimals: 18 + } + ] + ]) + const history = buildProtocolReturnHistorySeries({ + events: [ + baseEvent({ + kind: 'deposit', + id: 'unlocked-deposit', + vaultAddress: YVUSD_UNLOCKED, + familyVaultAddress: YVUSD_UNLOCKED, + blockTimestamp: 100, + shares: 100n * YVUSD_ONE, + assets: 100n * YVUSD_ONE, + owner: USER, + sender: USER + }), + baseEvent({ + kind: 'deposit', + id: 'locked-deposit', + vaultAddress: YVUSD_LOCKED, + familyVaultAddress: YVUSD_LOCKED, + blockTimestamp: 100, + shares: 2n * ONE, + assets: 200n * YVUSD_ONE, + owner: USER, + sender: USER + }) + ], + userAddress: USER, + metadata: combinedMetadata, + ppsData: new Map([ + [ + YVUSD_UNLOCKED_KEY, + new Map([ + [100, 1], + [200, 1.1] + ]) + ], + [ + YVUSD_LOCKED_KEY, + new Map([ + [100, 100], + [200, 110] + ]) + ] + ]), + priceData: new Map([[`ethereum:${YVUSD_UNDERLYING}`, new Map([[100, 1]])]]), + timestamps: [100, 200], + selectedVaultKeys: [YVUSD_UNLOCKED_KEY, YVUSD_LOCKED_KEY] + }) + + expect(history[0]?.currentUnderlying).toBeCloseTo(300) + expect(history[0]?.growthUnderlying).toBeCloseTo(0) + expect(history[1]?.currentUnderlying).toBeCloseTo(330) + expect(history[1]?.growthUnderlying).toBeCloseTo(30) + }) + + it('keeps ETH growth history available when another vault is missing receipt prices', () => { + const MISSING_VAULT = '0x5555555555555555555555555555555555555555' + const MISSING_ASSET = '0x6666666666666666666666666666666666666666' + const MISSING_VAULT_KEY = toVaultKey(1, MISSING_VAULT) + const mixedMetadata = new Map([ + ...metadata.entries(), + [ + MISSING_VAULT_KEY, + { + address: MISSING_VAULT, + chainId: 1, + version: 'v3', + category: 'stable', + token: { + address: MISSING_ASSET, + symbol: 'MISS', + decimals: 18 + }, + decimals: 18 + } + ] + ]) + + const history = buildProtocolReturnHistorySeries({ + events: [ + baseEvent({ + kind: 'deposit', + id: 'good-deposit', + blockTimestamp: 100, + shares: 100n * ONE, + assets: 100n * ONE, + owner: USER, + sender: USER + }), + baseEvent({ + kind: 'deposit', + id: 'missing-price-deposit', + vaultAddress: MISSING_VAULT, + familyVaultAddress: MISSING_VAULT, + blockTimestamp: 100, + logIndex: 1, + transactionHash: '0xmissing-price-deposit', + shares: 50n * ONE, + assets: 50n * ONE, + owner: USER, + sender: USER + }) + ], + userAddress: USER, + metadata: mixedMetadata, + ppsData: new Map([ + [ + VAULT_KEY, + new Map([ + [100, 1], + [200, 1.1] + ]) + ], + [ + MISSING_VAULT_KEY, + new Map([ + [100, 1], + [200, 1.1] + ]) + ] + ]), + priceData: new Map([[ASSET_PRICE_KEY, new Map([[0, 1]])]]), + ethPriceData: new Map([ + [0, 2], + [100, 2], + [200, 2] + ]), + timestamps: [100, 200] + }) + + expect(history[0]?.growthWeightEth).toBeCloseTo(0) + expect(history[1]?.growthWeightUsd).toBeCloseTo(10) + expect(history[1]?.growthWeightEth).toBeCloseTo(5) + }) + + it('does not double count the ERC4626 mint transfer alongside a deposit event', () => { + const vault = materializeVault({ + events: [ + baseEvent({ + kind: 'transfer', + id: 'mint-transfer', + blockTimestamp: 100, + transactionHash: '0xdeposit-tx', + sender: ZERO_ADDRESS, + receiver: USER, + shares: 100n * ONE + }), + baseEvent({ + kind: 'deposit', + id: 'deposit', + blockTimestamp: 100, + transactionHash: '0xdeposit-tx', + logIndex: 1, + shares: 100n * ONE, + assets: 100n * ONE, + owner: USER, + sender: USER + }) + ], + ppsData: new Map([ + [ + VAULT_KEY, + new Map([ + [100, 1], + [300, 1] + ]) + ] + ]), + priceData: new Map([[ASSET_PRICE_KEY, new Map([[100, 1]])]]) + }) + + expect(vault.sharesFormatted).toBeCloseTo(100) + expect(vault.baselineUnderlying).toBeCloseTo(100) + expect(vault.receiptCount).toBe(1) + }) + + it('does not double count the ERC4626 burn transfer alongside a withdrawal event', () => { + const vault = materializeVault({ + events: [ + baseEvent({ + kind: 'deposit', + id: 'deposit', + blockTimestamp: 100, + shares: 100n * ONE, + assets: 100n * ONE, + owner: USER, + sender: USER + }), + baseEvent({ + kind: 'transfer', + id: 'burn-transfer', + blockTimestamp: 200, + blockNumber: 2, + transactionHash: '0xwithdraw-tx', + sender: USER, + receiver: ZERO_ADDRESS, + shares: 100n * ONE + }), + baseEvent({ + kind: 'withdrawal', + id: 'withdrawal', + blockTimestamp: 200, + blockNumber: 2, + transactionHash: '0xwithdraw-tx', + logIndex: 1, + shares: 100n * ONE, + assets: 110n * ONE, + owner: USER + }) + ], + ppsData: new Map([ + [ + VAULT_KEY, + new Map([ + [100, 1], + [200, 1.1], + [300, 1.1] + ]) + ] + ]), + priceData: new Map([[ASSET_PRICE_KEY, new Map([[100, 1]])]]) + }) + + expect(vault.sharesFormatted).toBeCloseTo(0) + expect(vault.realizedBaselineUnderlying).toBeCloseTo(100) + expect(vault.realizedGrowthUnderlying).toBeCloseTo(10) + expect(vault.exitCount).toBe(1) + }) + + it('nets same-transaction transfer-ins that are immediately withdrawn', () => { + const vault = materializeVault({ + events: [ + baseEvent({ + kind: 'transfer', + id: 'router-transfer-in', + blockTimestamp: 100, + transactionHash: '0xrouter-exit', + sender: OTHER, + receiver: USER, + shares: 40n * ONE + }), + baseEvent({ + kind: 'withdrawal', + id: 'router-withdrawal', + blockTimestamp: 100, + transactionHash: '0xrouter-exit', + logIndex: 1, + shares: 30n * ONE, + assets: 30n * ONE, + owner: USER + }) + ], + ppsData: new Map([ + [ + VAULT_KEY, + new Map([ + [100, 1.1], + [300, 1.2] + ]) + ] + ]), + priceData: new Map([[ASSET_PRICE_KEY, new Map([[100, 1]])]]) + }) + + expect(vault.sharesFormatted).toBeCloseTo(10) + expect(vault.realizedGrowthUnderlying).toBeCloseTo(0) + expect(vault.unrealizedBaselineUnderlying).toBeCloseTo(11) + expect(vault.growthUnderlying).toBeCloseTo(1) + expect(vault.exitCount).toBe(0) + }) + + it('preserves accumulated growth across same-family staking unwrap transactions', () => { + const UNDERLYING_VAULT = '0x182863131F9a4630fF9E27830d945B1413e347E8' + const STAKING_VAULT = '0xd57aea3686d623da2dcebc87010a4f2f38ac7b15' + const STAKING_VAULT_KEY = toVaultKey(1, UNDERLYING_VAULT) + const stakingMetadata = new Map([ + [ + STAKING_VAULT_KEY, + { + address: UNDERLYING_VAULT, + chainId: 1, + version: 'v3', + category: 'stable', + token: { + address: ASSET, + symbol: 'TST', + decimals: 18 + }, + decimals: 18 + } + ] + ]) + const history = buildProtocolReturnHistorySeries({ + events: [ + baseEvent({ + kind: 'deposit', + id: 'deposit', + blockTimestamp: 100, + vaultAddress: UNDERLYING_VAULT, + familyVaultAddress: UNDERLYING_VAULT, + shares: 100n * ONE, + assets: 100n * ONE, + owner: USER, + sender: USER + }), + baseEvent({ + kind: 'withdrawal', + id: 'unstake-withdrawal', + transactionHash: '0xunstake', + vaultAddress: STAKING_VAULT, + familyVaultAddress: UNDERLYING_VAULT, + isStakingVault: true, + blockTimestamp: 200, + blockNumber: 2, + shares: 100n * ONE, + assets: 100n * ONE, + owner: USER + }), + baseEvent({ + kind: 'transfer', + id: 'unstake-transfer-in', + transactionHash: '0xunstake', + vaultAddress: UNDERLYING_VAULT, + familyVaultAddress: UNDERLYING_VAULT, + isStakingVault: false, + blockTimestamp: 200, + blockNumber: 2, + logIndex: 1, + sender: STAKING_VAULT, + receiver: USER, + shares: 100n * ONE + }) + ], + userAddress: USER, + metadata: stakingMetadata, + ppsData: new Map([ + [ + STAKING_VAULT_KEY, + new Map([ + [100, 1], + [150, 1.1], + [200, 1.1], + [250, 1.2] + ]) + ] + ]), + priceData: new Map([[ASSET_PRICE_KEY, new Map([[0, 1]])]]), + ethPriceData: new Map([ + [0, 2], + [100, 2], + [150, 2], + [200, 2], + [250, 2] + ]), + timestamps: [100, 150, 200, 250] + }) + + expect(history[1]?.growthWeightUsd).toBeCloseTo(10) + expect(history[1]?.growthWeightEth).toBeCloseTo(5) + expect(history[2]?.growthWeightUsd).toBeCloseTo(10) + expect(history[2]?.growthWeightEth).toBeCloseTo(5) + expect(history[3]?.growthWeightUsd).toBeCloseTo(20) + expect(history[3]?.growthWeightEth).toBeCloseTo(10) + expect(history[2]?.protocolReturnPct).toBeCloseTo(10) + }) + + it('uses family PPS for a staking wrapper unstake when underlying shares do not transfer back to the user', () => { + const UNDERLYING_VAULT = '0x182863131F9a4630fF9E27830d945B1413e347E8' + const STAKING_VAULT = '0xd57aea3686d623da2dcebc87010a4f2f38ac7b15' + const STAKING_VAULT_KEY = toVaultKey(1, UNDERLYING_VAULT) + const stakingMetadata = new Map([ + [ + STAKING_VAULT_KEY, + { + address: UNDERLYING_VAULT, + chainId: 1, + version: 'v3', + category: 'stable', + token: { + address: ASSET, + symbol: 'TST', + decimals: 18 + }, + decimals: 18 + } + ] + ]) + const history = buildProtocolReturnHistorySeries({ + events: [ + baseEvent({ + kind: 'deposit', + id: 'underlying-deposit', + blockTimestamp: 100, + vaultAddress: UNDERLYING_VAULT, + familyVaultAddress: UNDERLYING_VAULT, + shares: 100n * ONE, + assets: 125n * ONE, + owner: USER, + sender: USER + }), + baseEvent({ + kind: 'transfer', + id: 'stake-transfer-to-wrapper', + transactionHash: '0xstake', + vaultAddress: UNDERLYING_VAULT, + familyVaultAddress: UNDERLYING_VAULT, + blockTimestamp: 120, + blockNumber: 2, + sender: USER, + receiver: STAKING_VAULT, + shares: 100n * ONE + }), + baseEvent({ + kind: 'deposit', + id: 'stake-wrapper-deposit', + transactionHash: '0xstake', + vaultAddress: STAKING_VAULT, + familyVaultAddress: UNDERLYING_VAULT, + isStakingVault: true, + blockTimestamp: 120, + blockNumber: 2, + logIndex: 1, + shares: 100n * ONE, + assets: 100n * ONE, + owner: USER, + sender: USER + }), + baseEvent({ + kind: 'withdrawal', + id: 'unstake-wrapper-withdrawal', + transactionHash: '0xwallet-like-unstake', + vaultAddress: STAKING_VAULT, + familyVaultAddress: UNDERLYING_VAULT, + isStakingVault: true, + blockTimestamp: 200, + blockNumber: 3, + shares: 100n * ONE, + assets: 100n * ONE, + owner: USER + }) + ], + userAddress: USER, + metadata: stakingMetadata, + ppsData: new Map([ + [ + STAKING_VAULT_KEY, + new Map([ + [100, 1.25], + [120, 1.25], + [150, 1.4], + [200, 1.5] + ]) + ] + ]), + priceData: new Map([[ASSET_PRICE_KEY, new Map([[0, 1]])]]), + ethPriceData: new Map([ + [0, 2], + [100, 2], + [120, 2], + [150, 2], + [200, 2] + ]), + timestamps: [100, 150, 200] + }) + + expect(history[1]?.growthWeightUsd).toBeCloseTo(15) + expect(history[2]?.growthWeightUsd).toBeCloseTo(25) + expect(history[2]?.growthWeightEth).toBeCloseTo(12.5) + expect(history[2]?.protocolReturnPct).toBeCloseTo(20) + expect(history[2]?.growthIndex).toBeGreaterThan(100) + }) + + it('treats mixed unstake transfer-ins identically regardless of transfer order', () => { + const UNDERLYING_VAULT = '0x182863131F9a4630fF9E27830d945B1413e347E8' + const STAKING_VAULT = '0xd57aea3686d623da2dcebc87010a4f2f38ac7b15' + const STAKING_VAULT_KEY = toVaultKey(1, UNDERLYING_VAULT) + const stakingMetadata = new Map([ + [ + STAKING_VAULT_KEY, + { + address: UNDERLYING_VAULT, + chainId: 1, + version: 'v3', + category: 'stable', + token: { + address: ASSET, + symbol: 'TST', + decimals: 18 + }, + decimals: 18 + } + ] + ]) + + const materializeMixedUnstake = (order: 'bonus-first' | 'unwrap-first'): HoldingsPnLSimpleVault => { + const bonusTransfer = baseEvent({ + kind: 'transfer', + id: `bonus-${order}`, + transactionHash: `0xmix-${order}`, + vaultAddress: UNDERLYING_VAULT, + familyVaultAddress: UNDERLYING_VAULT, + isStakingVault: false, + blockTimestamp: 200, + blockNumber: 2, + logIndex: order === 'bonus-first' ? 1 : 2, + sender: STAKING_VAULT, + receiver: USER, + shares: 10n * ONE + }) + const unwrapTransfer = baseEvent({ + kind: 'transfer', + id: `unwrap-${order}`, + transactionHash: `0xmix-${order}`, + vaultAddress: UNDERLYING_VAULT, + familyVaultAddress: UNDERLYING_VAULT, + isStakingVault: false, + blockTimestamp: 200, + blockNumber: 2, + logIndex: order === 'bonus-first' ? 2 : 1, + sender: STAKING_VAULT, + receiver: USER, + shares: 100n * ONE + }) + + const events = [ + baseEvent({ + kind: 'deposit', + id: `initial-${order}`, + blockTimestamp: 50, + vaultAddress: UNDERLYING_VAULT, + familyVaultAddress: UNDERLYING_VAULT, + shares: 100n * ONE, + assets: 100n * ONE, + owner: USER, + sender: USER + }), + baseEvent({ + kind: 'withdrawal', + id: `unstake-${order}`, + transactionHash: `0xmix-${order}`, + vaultAddress: STAKING_VAULT, + familyVaultAddress: UNDERLYING_VAULT, + isStakingVault: true, + blockTimestamp: 200, + blockNumber: 2, + logIndex: 0, + shares: 100n * ONE, + assets: 100n * ONE, + owner: USER + }), + ...(order === 'bonus-first' ? [bonusTransfer, unwrapTransfer] : [unwrapTransfer, bonusTransfer]) + ] + + const ppsData = new Map([ + [ + STAKING_VAULT_KEY, + new Map([ + [50, 1], + [200, 1.1], + [300, 1.2] + ]) + ] + ]) + const priceData = new Map([ + [ + ASSET_PRICE_KEY, + new Map([ + [0, 1], + [50, 1], + [200, 1] + ]) + ] + ]) + const ledgers = buildProtocolReturnLedgers({ + events, + userAddress: USER, + metadata: stakingMetadata, + ppsData, + priceData, + currentTimestamp: 300 + }) + + return materializeProtocolReturnVaults({ + ledgers, + metadata: stakingMetadata, + ppsData, + currentTimestamp: 300 + })[0]! + } + + const bonusFirst = materializeMixedUnstake('bonus-first') + const unwrapFirst = materializeMixedUnstake('unwrap-first') + + expect(bonusFirst.sharesFormatted).toBeCloseTo(110) + expect(unwrapFirst.sharesFormatted).toBeCloseTo(110) + expect(bonusFirst.receiptCount).toBe(2) + expect(unwrapFirst.receiptCount).toBe(2) + expect(bonusFirst.baselineUnderlying).toBeCloseTo(111) + expect(unwrapFirst.baselineUnderlying).toBeCloseTo(111) + expect(bonusFirst.growthUnderlying).toBeCloseTo(21) + expect(unwrapFirst.growthUnderlying).toBeCloseTo(21) + expect(bonusFirst.protocolReturnPct).toBeCloseTo(unwrapFirst.protocolReturnPct ?? 0) + expect(bonusFirst.growthWeightUsd).toBeCloseTo(unwrapFirst.growthWeightUsd) + }) + + it('uses tx-scoped underlying deposit assets as the basis for router-mediated staking receipts', () => { + const UNDERLYING_VAULT = '0x182863131F9a4630fF9E27830d945B1413e347E8' + const STAKING_VAULT = '0xd57aea3686d623da2dcebc87010a4f2f38ac7b15' + const STAKING_VAULT_KEY = toVaultKey(1, UNDERLYING_VAULT) + const stakingMetadata = new Map([ + [ + STAKING_VAULT_KEY, + { + address: UNDERLYING_VAULT, + chainId: 1, + version: 'v3', + category: 'stable', + token: { + address: ASSET, + symbol: 'TST', + decimals: 18 + }, + decimals: 18 + } + ] + ]) + + const history = buildProtocolReturnHistorySeries({ + events: [ + baseEvent({ + kind: 'deposit', + id: 'router-underlying-deposit', + transactionHash: '0xrouter-stake', + vaultAddress: UNDERLYING_VAULT, + familyVaultAddress: UNDERLYING_VAULT, + isStakingVault: false, + blockTimestamp: 200, + blockNumber: 2, + logIndex: 0, + shares: 100n * ONE, + assets: 110n * ONE, + owner: OTHER, + sender: OTHER, + scopes: { + address: false, + tx: true + } + }), + baseEvent({ + kind: 'transfer', + id: 'router-transfer-to-staking', + transactionHash: '0xrouter-stake', + vaultAddress: UNDERLYING_VAULT, + familyVaultAddress: UNDERLYING_VAULT, + isStakingVault: false, + blockTimestamp: 200, + blockNumber: 2, + logIndex: 1, + sender: OTHER, + receiver: STAKING_VAULT, + shares: 100n * ONE, + scopes: { + address: false, + tx: true + } + }), + baseEvent({ + kind: 'deposit', + id: 'user-staking-deposit', + transactionHash: '0xrouter-stake', + vaultAddress: STAKING_VAULT, + familyVaultAddress: UNDERLYING_VAULT, + isStakingVault: true, + blockTimestamp: 200, + blockNumber: 2, + logIndex: 2, + shares: 100n * ONE, + assets: 100n * ONE, + owner: USER, + sender: OTHER, + scopes: { + address: true, + tx: true + } + }), + baseEvent({ + kind: 'transfer', + id: 'user-staking-mint-transfer', + transactionHash: '0xrouter-stake', + vaultAddress: STAKING_VAULT, + familyVaultAddress: UNDERLYING_VAULT, + isStakingVault: true, + blockTimestamp: 200, + blockNumber: 2, + logIndex: 3, + sender: ZERO_ADDRESS, + receiver: USER, + shares: 100n * ONE, + scopes: { + address: true, + tx: true + } + }) + ], + userAddress: USER, + metadata: stakingMetadata, + ppsData: new Map([ + [ + STAKING_VAULT_KEY, + new Map([ + [200, 1.1], + [250, 1.2] + ]) + ] + ]), + priceData: new Map([ + [ + ASSET_PRICE_KEY, + new Map([ + [0, 1], + [200, 1] + ]) + ] + ]), + ethPriceData: new Map([ + [0, 2], + [200, 2], + [250, 2] + ]), + timestamps: [200, 250] + }) + + expect(history[0]?.growthWeightUsd).toBeCloseTo(0) + expect(history[0]?.growthWeightEth).toBeCloseTo(0) + expect(history[0]?.protocolReturnPct).toBeCloseTo(0) + expect(history[0]?.growthIndex).toBeCloseTo(100) + expect(history[1]?.growthWeightUsd).toBeCloseTo(10) + expect(history[1]?.growthWeightEth).toBeCloseTo(5) + expect(history[1]?.protocolReturnPct).toBeCloseTo(9.0909090909) + expect(history[1]?.growthIndex).toBeCloseTo(109.0909090909) + }) + + it('recovers router-mediated staking basis when tx activity is merged into address-scoped simple-history events', () => { + const UNDERLYING_VAULT = '0x182863131F9a4630fF9E27830d945B1413e347E8' + const STAKING_VAULT = '0xd57aea3686d623da2dcebc87010a4f2f38ac7b15' + const STAKING_VAULT_KEY = toVaultKey(1, UNDERLYING_VAULT) + const stakingMetadata = new Map([ + [ + STAKING_VAULT_KEY, + { + address: UNDERLYING_VAULT, + chainId: 1, + version: 'v3', + category: 'stable', + token: { + address: ASSET, + symbol: 'TST', + decimals: 18 + }, + decimals: 18 + } + ] + ]) + + const addressEvents = [ + baseEvent({ + kind: 'deposit', + id: 'user-staking-deposit', + transactionHash: '0xrouter-stake', + vaultAddress: STAKING_VAULT, + familyVaultAddress: UNDERLYING_VAULT, + isStakingVault: true, + blockTimestamp: 200, + blockNumber: 2, + logIndex: 2, + shares: 100n * ONE, + assets: 100n * ONE, + owner: USER, + sender: OTHER, + scopes: { + address: true, + tx: false + } + }), + baseEvent({ + kind: 'transfer', + id: 'user-staking-mint-transfer', + transactionHash: '0xrouter-stake', + vaultAddress: STAKING_VAULT, + familyVaultAddress: UNDERLYING_VAULT, + isStakingVault: true, + blockTimestamp: 200, + blockNumber: 2, + logIndex: 3, + sender: ZERO_ADDRESS, + receiver: USER, + shares: 100n * ONE, + scopes: { + address: true, + tx: false + } + }) + ] + const transactionEvents: TransactionActivityEvents = { + deposits: [ + { + id: 'router-underlying-deposit', + vaultAddress: UNDERLYING_VAULT, + chainId: 1, + blockNumber: 2, + blockTimestamp: 200, + logIndex: 0, + transactionHash: '0xrouter-stake', + transactionFrom: OTHER, + owner: OTHER, + sender: OTHER, + assets: (110n * ONE).toString(), + shares: (100n * ONE).toString() + } + ], + withdrawals: [], + transfers: [ + { + id: 'router-transfer-to-staking', + vaultAddress: UNDERLYING_VAULT, + chainId: 1, + blockNumber: 2, + blockTimestamp: 200, + logIndex: 1, + transactionHash: '0xrouter-stake', + transactionFrom: OTHER, + sender: OTHER, + receiver: STAKING_VAULT, + value: (100n * ONE).toString() + } + ] + } + + const history = buildProtocolReturnHistorySeries({ + events: mergeAddressScopedRawPnlEventsWithTransactionActivity(addressEvents, transactionEvents), + userAddress: USER, + metadata: stakingMetadata, + ppsData: new Map([ + [ + STAKING_VAULT_KEY, + new Map([ + [200, 1.1], + [250, 1.2] + ]) + ] + ]), + priceData: new Map([ + [ + ASSET_PRICE_KEY, + new Map([ + [0, 1], + [200, 1] + ]) + ] + ]), + ethPriceData: new Map([ + [0, 2], + [200, 2], + [250, 2] + ]), + timestamps: [200, 250] + }) + + expect(history[0]?.growthWeightUsd).toBeCloseTo(0) + expect(history[0]?.protocolReturnPct).toBeCloseTo(0) + expect(history[0]?.growthIndex).toBeCloseTo(100) + expect(history[1]?.growthWeightUsd).toBeCloseTo(10) + expect(history[1]?.growthWeightEth).toBeCloseTo(5) + expect(history[1]?.protocolReturnPct).toBeCloseTo(9.0909090909) + expect(history[1]?.growthIndex).toBeCloseTo(109.0909090909) + }) + + it('builds family comparison history for selected vaults', () => { + const ledgers = buildProtocolReturnLedgers({ + events: [ + baseEvent({ + kind: 'deposit', + id: 'deposit', + shares: 100n * ONE, + assets: 100n * ONE, + owner: USER, + sender: USER + }) + ], + userAddress: USER, + metadata, + ppsData: new Map([ + [ + VAULT_KEY, + new Map([ + [100, 1], + [200, 1.1] + ]) + ] + ]), + priceData: new Map([[ASSET_PRICE_KEY, new Map([[100, 1]])]]), + currentTimestamp: 200 + }) + const selectedVault = materializeProtocolReturnVaults({ + ledgers, + metadata, + ppsData: new Map([ + [ + VAULT_KEY, + new Map([ + [100, 1], + [200, 1.1] + ]) + ] + ]), + currentTimestamp: 200 + })[0]! + + const familyHistory = buildProtocolReturnFamilyHistorySeries({ + events: [ + baseEvent({ + kind: 'deposit', + id: 'deposit', + shares: 100n * ONE, + assets: 100n * ONE, + owner: USER, + sender: USER + }) + ], + userAddress: USER, + metadata, + ppsData: new Map([ + [ + VAULT_KEY, + new Map([ + [100, 1], + [200, 1.1] + ]) + ] + ]), + priceData: new Map([[ASSET_PRICE_KEY, new Map([[100, 1]])]]), + timestamps: [100, 200], + selectedVaults: [selectedVault] + }) + + expect(familyHistory).toHaveLength(1) + expect(familyHistory[0]?.vaultAddress).toBe(VAULT) + expect(familyHistory[0]?.dataPoints[0]?.protocolReturnPct).toBeCloseTo(0) + expect(familyHistory[0]?.dataPoints[1]?.protocolReturnPct).toBeCloseTo(10) + expect(familyHistory[0]?.dataPoints[0]?.growthIndex).toBeCloseTo(100) + expect(familyHistory[0]?.dataPoints[1]?.growthIndex).toBeCloseTo(110) + }) + + it('keeps locked and unlocked yvUSD as separate protocol-return families', () => { + const yvUsdMetadata = new Map([ + [ + YVUSD_UNLOCKED_KEY, + { + address: YVUSD_UNLOCKED, + chainId: 1, + version: 'v3', + category: 'stable', + token: { + address: YVUSD_UNDERLYING, + symbol: 'USDC', + decimals: 6 + }, + decimals: 6 + } + ], + [ + YVUSD_LOCKED_KEY, + { + address: YVUSD_LOCKED, + chainId: 1, + version: 'v3', + category: 'stable', + token: { + address: YVUSD_UNLOCKED, + symbol: 'yvUSD', + decimals: 6 + }, + decimals: 6 + } + ] + ]) + const yvUsdPpsData = new Map([ + [ + YVUSD_UNLOCKED_KEY, + new Map([ + [100, 1], + [200, 1.02] + ]) + ], + [ + YVUSD_LOCKED_KEY, + new Map([ + [100, 1], + [200, 1.05] + ]) + ] + ]) + const yvUsdPriceData = new Map([ + [ + `ethereum:${YVUSD_UNDERLYING}`, + new Map([ + [100, 1], + [200, 1] + ]) + ], + [ + `ethereum:${YVUSD_UNLOCKED}`, + new Map([ + [100, 1.01], + [200, 1.02] + ]) + ] + ]) + const events = [ + baseEvent({ + kind: 'deposit', + id: 'unlocked-deposit', + vaultAddress: YVUSD_UNLOCKED, + familyVaultAddress: YVUSD_UNLOCKED, + shares: 100n * YVUSD_ONE, + assets: 100n * YVUSD_ONE, + owner: USER, + sender: USER + }), + baseEvent({ + kind: 'deposit', + id: 'locked-deposit', + vaultAddress: YVUSD_LOCKED, + familyVaultAddress: YVUSD_LOCKED, + blockTimestamp: 100, + blockNumber: 1, + logIndex: 1, + transactionHash: '0xlocked-deposit', + shares: 100n * YVUSD_ONE, + assets: 100n * YVUSD_ONE, + owner: USER, + sender: USER + }) + ] + const ledgers = buildProtocolReturnLedgers({ + events, + userAddress: USER, + metadata: yvUsdMetadata, + ppsData: yvUsdPpsData, + priceData: yvUsdPriceData, + currentTimestamp: 200 + }) + const selectedVaults = materializeProtocolReturnVaults({ + ledgers, + metadata: yvUsdMetadata, + ppsData: yvUsdPpsData, + currentTimestamp: 200 + }) + const lockedVault = selectedVaults.find((vault) => vault.vaultAddress === YVUSD_LOCKED) + const familyHistory = buildProtocolReturnFamilyHistorySeries({ + events, + userAddress: USER, + metadata: yvUsdMetadata, + ppsData: yvUsdPpsData, + priceData: yvUsdPriceData, + timestamps: [100, 200], + selectedVaults + }) + + expect(selectedVaults).toHaveLength(2) + expect(lockedVault?.currentUnderlying).toBeCloseTo(105) + expect(lockedVault?.growthUnderlying).toBeCloseTo(5) + expect(lockedVault?.growthWeightUsd).toBeCloseTo(5.05) + expect(familyHistory.map((series) => series.vaultAddress).sort()).toEqual([YVUSD_LOCKED, YVUSD_UNLOCKED].sort()) + expect( + familyHistory.find((series) => series.vaultAddress === YVUSD_LOCKED)?.dataPoints[1]?.growthWeightUsd + ).toBeCloseTo(5.05) + expect( + familyHistory.find((series) => series.vaultAddress === YVUSD_UNLOCKED)?.dataPoints[1]?.growthWeightUsd + ).toBeCloseTo(2) + }) + + it('values locked yvUSD withdrawals as normal nested-vault exits, not staking wrapper exits', () => { + const yvUsdMetadata = new Map([ + [ + YVUSD_LOCKED_KEY, + { + address: YVUSD_LOCKED, + chainId: 1, + version: 'v3', + category: 'stable', + token: { + address: YVUSD_UNLOCKED, + symbol: 'yvUSD', + decimals: 6 + }, + decimals: 6 + } + ] + ]) + const ppsData = new Map([ + [ + YVUSD_LOCKED_KEY, + new Map([ + [100, 1], + [200, 1.05], + [300, 1.05] + ]) + ] + ]) + const ledgers = buildProtocolReturnLedgers({ + events: [ + baseEvent({ + kind: 'deposit', + id: 'locked-deposit', + vaultAddress: YVUSD_LOCKED, + familyVaultAddress: YVUSD_LOCKED, + blockTimestamp: 100, + shares: 100n * YVUSD_ONE, + assets: 100n * YVUSD_ONE, + owner: USER, + sender: USER + }), + baseEvent({ + kind: 'withdrawal', + id: 'locked-withdrawal', + vaultAddress: YVUSD_LOCKED, + familyVaultAddress: YVUSD_LOCKED, + blockTimestamp: 200, + blockNumber: 2, + shares: 100n * YVUSD_ONE, + assets: 105n * YVUSD_ONE, + owner: USER + }) + ], + userAddress: USER, + metadata: yvUsdMetadata, + ppsData, + priceData: new Map([[`ethereum:${YVUSD_UNLOCKED}`, new Map([[100, 1.01]])]]), + currentTimestamp: 300 + }) + const vault = materializeProtocolReturnVaults({ + ledgers, + metadata: yvUsdMetadata, + ppsData, + currentTimestamp: 300 + })[0]! + + expect(vault.status).toBe('ok') + expect(vault.realizedGrowthUnderlying).toBeCloseTo(5) + expect(vault.growthWeightUsd).toBeCloseTo(5.05) + expect(vault.protocolReturnPct).toBeCloseTo(5) + }) + + it('stops emitting family growth index points after a vault is fully exited', () => { + const ledgers = buildProtocolReturnLedgers({ + events: [ + baseEvent({ + kind: 'deposit', + id: 'deposit', + blockTimestamp: 100, + shares: 100n * ONE, + assets: 100n * ONE, + owner: USER, + sender: USER + }), + baseEvent({ + kind: 'withdrawal', + id: 'withdrawal', + blockTimestamp: 200, + blockNumber: 2, + shares: 100n * ONE, + assets: 110n * ONE, + owner: USER + }) + ], + userAddress: USER, + metadata, + ppsData: new Map([ + [ + VAULT_KEY, + new Map([ + [100, 1], + [200, 1.1], + [300, 1.1] + ]) + ] + ]), + priceData: new Map([[ASSET_PRICE_KEY, new Map([[100, 1]])]]), + currentTimestamp: 300 + }) + const selectedVault = materializeProtocolReturnVaults({ + ledgers, + metadata, + ppsData: new Map([ + [ + VAULT_KEY, + new Map([ + [100, 1], + [200, 1.1], + [300, 1.1] + ]) + ] + ]), + currentTimestamp: 300 + })[0]! + + const familyHistory = buildProtocolReturnFamilyHistorySeries({ + events: [ + baseEvent({ + kind: 'deposit', + id: 'deposit', + blockTimestamp: 100, + shares: 100n * ONE, + assets: 100n * ONE, + owner: USER, + sender: USER + }), + baseEvent({ + kind: 'withdrawal', + id: 'withdrawal', + blockTimestamp: 200, + blockNumber: 2, + shares: 100n * ONE, + assets: 110n * ONE, + owner: USER + }) + ], + userAddress: USER, + metadata, + ppsData: new Map([ + [ + VAULT_KEY, + new Map([ + [100, 1], + [200, 1.1], + [300, 1.1] + ]) + ] + ]), + priceData: new Map([[ASSET_PRICE_KEY, new Map([[100, 1]])]]), + timestamps: [100, 200, 300], + selectedVaults: [selectedVault] + }) + + expect(familyHistory[0]?.dataPoints[0]?.growthIndex).toBeCloseTo(100) + expect(familyHistory[0]?.dataPoints[0]?.protocolReturnPct).toBeCloseTo(0) + expect(familyHistory[0]?.dataPoints[1]?.growthIndex).toBeNull() + expect(familyHistory[0]?.dataPoints[1]?.protocolReturnPct).toBeNull() + expect(familyHistory[0]?.dataPoints[2]?.growthIndex).toBeNull() + expect(familyHistory[0]?.dataPoints[2]?.protocolReturnPct).toBeNull() + }) + + it('builds an aggregate growth index that does not step down when a new position opens', () => { + const SECOND_VAULT = '0x0000000000000000000000000000000000000002' + const SECOND_ASSET = '0x0000000000000000000000000000000000000003' + const secondVaultKey = toVaultKey(1, SECOND_VAULT) + const secondAssetPriceKey = `ethereum:${SECOND_ASSET.toLowerCase()}` + const extendedMetadata = new Map([ + ...metadata, + [ + secondVaultKey, + { + address: SECOND_VAULT, + chainId: 1, + version: 'v3', + category: 'volatile', + token: { + address: SECOND_ASSET, + symbol: 'ETH', + decimals: 18 + }, + decimals: 18 + } + ] + ]) + + const history = buildProtocolReturnHistorySeries({ + events: [ + baseEvent({ + kind: 'deposit', + id: 'first-deposit', + blockTimestamp: 100, + shares: 100n * ONE, + assets: 100n * ONE, + owner: USER, + sender: USER + }), + baseEvent({ + kind: 'deposit', + id: 'second-deposit', + vaultAddress: SECOND_VAULT, + familyVaultAddress: SECOND_VAULT, + blockTimestamp: 200, + blockNumber: 2, + shares: 100n * ONE, + assets: 100n * ONE, + owner: USER, + sender: USER + }) + ], + userAddress: USER, + metadata: extendedMetadata, + ppsData: new Map([ + [ + VAULT_KEY, + new Map([ + [100, 1], + [200, 1.1], + [300, 1.2] + ]) + ], + [ + secondVaultKey, + new Map([ + [200, 1], + [300, 1] + ]) + ] + ]), + priceData: new Map([ + [ + ASSET_PRICE_KEY, + new Map([ + [100, 1], + [200, 1], + [300, 1] + ]) + ], + [ + secondAssetPriceKey, + new Map([ + [200, 1], + [300, 1] + ]) + ] + ]), + timestamps: [100, 200, 300] + }) + + expect(history[0]?.protocolReturnPct).toBeCloseTo(0) + expect(history[1]?.protocolReturnPct).toBeCloseTo(5) + expect(history[2]?.protocolReturnPct).toBeCloseTo(10) + expect(history[0]?.growthIndex).toBeCloseTo(100) + expect(history[1]?.growthIndex).toBeCloseTo(110) + expect(history[2]?.growthIndex).toBeCloseTo(115.5) + }) + + it('does not over-assign deposit basis when a same-tx mint is partially forwarded out', () => { + const vault = materializeVault({ + events: [ + baseEvent({ + kind: 'deposit', + id: 'router-deposit', + transactionHash: '0xrouter', + blockTimestamp: 100, + shares: 100n * ONE, + assets: 100n * ONE, + owner: USER, + sender: USER + }), + baseEvent({ + kind: 'transfer', + id: 'router-forward', + transactionHash: '0xrouter', + blockTimestamp: 100, + logIndex: 1, + sender: USER, + receiver: OTHER, + shares: 20n * ONE + }) + ], + ppsData: new Map([[VAULT_KEY, new Map([[300, 1]])]]), + priceData: new Map([[ASSET_PRICE_KEY, new Map([[100, 1]])]]) + }) + + expect(vault.sharesFormatted).toBeCloseTo(80) + expect(vault.realizedBaselineUnderlying).toBeCloseTo(20) + expect(vault.unrealizedBaselineUnderlying).toBeCloseTo(80) + expect(vault.growthUnderlying).toBeCloseTo(0) + expect(vault.protocolReturnPct).toBeCloseTo(0) + }) + + it('does not realize fake gains on same-tx same-family rollover flows', () => { + const vault = materializeVault({ + events: [ + baseEvent({ + kind: 'deposit', + id: 'initial-deposit', + blockTimestamp: 50, + shares: 100n * ONE, + assets: 100n * ONE, + owner: USER, + sender: USER + }), + baseEvent({ + kind: 'withdrawal', + id: 'rollover-withdrawal', + transactionHash: '0xrollover', + blockTimestamp: 100, + blockNumber: 2, + shares: 100n * ONE, + assets: 100n * ONE, + owner: USER + }), + baseEvent({ + kind: 'deposit', + id: 'rollover-redeposit', + transactionHash: '0xrollover', + blockTimestamp: 100, + blockNumber: 2, + logIndex: 1, + shares: 90n * ONE, + assets: 100n * ONE, + owner: USER, + sender: USER + }) + ], + ppsData: new Map([ + [ + VAULT_KEY, + new Map([ + [50, 1], + [100, 1], + [300, 1] + ]) + ] + ]), + priceData: new Map([ + [ + ASSET_PRICE_KEY, + new Map([ + [50, 1], + [100, 1] + ]) + ] + ]) + }) + + expect(vault.realizedGrowthUnderlying).toBeCloseTo(0) + expect(vault.growthUnderlying).toBeCloseTo(-10) + expect(vault.protocolReturnPct).toBeCloseTo(-5) + expect(vault.baselineUnderlying).toBeCloseTo(200) + expect(vault.sharesFormatted).toBeCloseTo(90) + }) + + it('closes positions when a user transfer-out is paired with a tx-scoped intermediary withdrawal', () => { + const vault = materializeVault({ + events: [ + baseEvent({ + kind: 'deposit', + id: 'deposit', + blockTimestamp: 100, + shares: 100n * ONE, + assets: 100n * ONE, + owner: USER, + sender: USER + }), + baseEvent({ + kind: 'transfer', + id: 'user-transfer-out', + blockTimestamp: 200, + blockNumber: 2, + transactionHash: '0xrouter-exit', + sender: USER, + receiver: OTHER, + shares: 100n * ONE, + scopes: { + address: true, + tx: true + } + }), + baseEvent({ + kind: 'withdrawal', + id: 'router-withdrawal', + blockTimestamp: 200, + blockNumber: 2, + transactionHash: '0xrouter-exit', + owner: OTHER, + shares: 100n * ONE, + assets: 110n * ONE, + scopes: { + address: false, + tx: true + } + }) + ], + ppsData: new Map([ + [ + VAULT_KEY, + new Map([ + [100, 1], + [200, 1.1], + [300, 1.1] + ]) + ] + ]), + priceData: new Map([[ASSET_PRICE_KEY, new Map([[100, 1]])]]) + }) + + expect(vault.sharesFormatted).toBeCloseTo(0) + expect(vault.currentUnderlying).toBeCloseTo(0) + expect(vault.realizedBaselineUnderlying).toBeCloseTo(100) + expect(vault.realizedGrowthUnderlying).toBeCloseTo(10) + expect(vault.unrealizedBaselineUnderlying).toBeCloseTo(0) + }) +}) diff --git a/api/lib/holdings/services/pnlSimple.ts b/api/lib/holdings/services/pnlSimple.ts new file mode 100644 index 000000000..406ae39b2 --- /dev/null +++ b/api/lib/holdings/services/pnlSimple.ts @@ -0,0 +1,2274 @@ +import { formatUnits } from 'viem' +import { holdingsConfig } from '../config' +import type { VaultMetadata } from '../types' +import { debugError, debugLog, reportHoldingsProgress } from './debug' +import { + fetchHistoricalPricesForTokenTimestamps, + getChainPrefix, + getPriceAtTimestamp, + type THistoricalPriceRequest +} from './defillama' +import { + fetchActivityEventsByTransactionHashes, + type HoldingsEventFetchType, + type HoldingsEventPaginationMode, + type VaultVersion +} from './graphql' +import { + generateDailyTimestamps, + generateDailyTimestampsFromRange, + timestampToDateString, + toSettledDayTimestamp +} from './holdings' +import { getPPS } from './kong' +import { + deriveNestedVaultAssetPriceData, + expandNestedVaultAssetPriceRequests, + getNestedVaultPpsIdentifiersFromPriceRequests, + mergeVaultIdentifiers +} from './nestedVaultPrices' +import { mergeAddressScopedRawPnlEventsWithTransactionActivity } from './pnlEvents' +import { lowerCaseAddress, toVaultKey, ZERO } from './pnlShared' +import type { TRawPnlEvent } from './pnlTypes' +import { getSettledVersionedPpsContext, getVaultIdentifiers } from './settledHoldingsContext' +import { getStakingVaultAddress } from './staking' + +type TProtocolReturnIssue = 'missing_metadata' | 'missing_pps' | 'missing_receipt_price' | 'unmatched_exit' + +type TProtocolReturnReceiptKind = 'deposit' | 'transfer_in' +type TProtocolReturnExitKind = 'withdrawal' | 'transfer_out' + +const RECEIPT_PRICE_BUCKET_SECONDS = 24 * 60 * 60 +const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000' +const ETHEREUM_WETH_ADDRESS = '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2' +const ETHEREUM_WETH_PRICE_KEY = `${getChainPrefix(1)}:${ETHEREUM_WETH_ADDRESS.toLowerCase()}` +const ETH_FAMILY_SYMBOLS = new Set([ + 'ETH', + 'WETH', + 'STETH', + 'WSTETH', + 'RETH', + 'FRXETH', + 'SFRXETH', + 'EETH', + 'WEETH', + 'EZETH', + 'METH', + 'MSETH' +]) + +type TProtocolReturnLot = { + shares: bigint + baselineUnderlying: number + receiptTimestamp: number + receiptPriceUsd: number + receiptPriceEth: number + receiptPriceMissing: boolean + receiptPriceEthMissing: boolean + receiptKind: TProtocolReturnReceiptKind + transactionHash: string +} + +type TProtocolReturnConsumedLot = { + shares: bigint + baselineUnderlying: number + receiptTimestamp: number + receiptPriceUsd: number + receiptPriceEth: number + receiptPriceMissing: boolean + receiptPriceEthMissing: boolean +} + +type TProtocolReturnLedger = { + chainId: number + vaultAddress: string + lots: TProtocolReturnLot[] + baselineUnderlying: number + baselineExposureUnderlyingSeconds: number + baselineExposureWeightUsdSeconds: number + realizedBaselineUnderlying: number + realizedGrowthUnderlying: number + realizedBaselineWeightUsd: number + realizedGrowthWeightUsd: number + realizedGrowthWeightEth: number + unmatchedExitShares: bigint + unmatchedExitCount: number + receiptCount: number + exitCount: number + deposits: number + withdrawals: number + transfersIn: number + transfersOut: number + missingPps: boolean + missingReceiptPrice: boolean + missingReceiptEthPrice: boolean + lastAccruedTimestamp: number | null +} + +export type TSimpleReceiptPriceRequest = THistoricalPriceRequest + +export type THoldingsPnLSimpleStatus = 'ok' | 'missing_metadata' | 'missing_pps' | 'missing_receipt_price' | 'partial' +export type TGrowthDisplay = 'usd' | 'eth' | 'index' +export type TGrowthDisplayReason = 'stable_dominant' | 'eth_dominant' | 'mixed' + +export interface HoldingsPnLSimpleVault { + chainId: number + vaultAddress: string + status: THoldingsPnLSimpleStatus + issues: TProtocolReturnIssue[] + shares: string + sharesFormatted: number + pricePerShare: number + currentUnderlying: number + baselineUnderlying: number + baselineExposureUnderlyingYears: number + baselineExposureWeightUsdYears: number + realizedBaselineUnderlying: number + unrealizedBaselineUnderlying: number + realizedGrowthUnderlying: number + unrealizedGrowthUnderlying: number + growthUnderlying: number + baselineWeightUsd: number + growthWeightUsd: number + realizedGrowthWeightUsd: number + unrealizedGrowthWeightUsd: number + protocolReturnPct: number | null + annualizedProtocolReturnPct: number | null + receiptCount: number + exitCount: number + deposits: number + withdrawals: number + transfersIn: number + transfersOut: number + unmatchedExitShares: string + unmatchedExitSharesFormatted: number + metadata: { + symbol: string | null + decimals: number + assetDecimals: number + tokenAddress: string | null + } +} + +export interface HoldingsPnLSimpleResponse { + address: string + version: VaultVersion + generatedAt: string + summary: { + totalVaults: number + completeVaults: number + partialVaults: number + baselineWeightUsd: number + growthWeightUsd: number + baselineExposureWeightUsdYears: number + realizedGrowthWeightUsd: number + unrealizedGrowthWeightUsd: number + protocolReturnPct: number | null + annualizedProtocolReturnPct: number | null + isComplete: boolean + } + vaults: HoldingsPnLSimpleVault[] +} + +export interface HoldingsPnLSimpleHistoryPoint { + date: string + timestamp: number + growthWeightUsd: number + growthWeightEth: number | null + protocolReturnPct: number | null + annualizedProtocolReturnPct: number | null + growthIndex: number | null + currentUnderlying?: number + growthUnderlying?: number + sharesFormatted?: number + pricePerShare?: number +} + +export interface HoldingsPnLSimpleHistoryFamilyPoint { + date: string + timestamp: number + protocolReturnPct: number | null + growthWeightUsd: number | null + growthIndex: number | null +} + +export interface HoldingsPnLSimpleHistoryFamilySeries { + chainId: number + vaultAddress: string + symbol: string | null + status: THoldingsPnLSimpleStatus + dataPoints: HoldingsPnLSimpleHistoryFamilyPoint[] +} + +export interface HoldingsPnLSimpleHistoryResponse { + address: string + version: VaultVersion + timeframe: '1y' | 'all' + generatedAt: string + summary: { + totalVaults: number + completeVaults: number + partialVaults: number + recommendedGrowthDisplay: TGrowthDisplay + recommendedGrowthDisplayReason: TGrowthDisplayReason + openBaselineCompositionUsd: { + stable: number + ethFamily: number + other: number + } + isComplete: boolean + } + dataPoints: HoldingsPnLSimpleHistoryPoint[] + familySeries: HoldingsPnLSimpleHistoryFamilySeries[] +} + +const SECONDS_PER_YEAR = 365 * 24 * 60 * 60 + +type TProtocolReturnVaultFilter = { chainId: number; vaultAddress: string } + +function emptyLedger(chainId: number, vaultAddress: string): TProtocolReturnLedger { + return { + chainId, + vaultAddress, + lots: [], + baselineUnderlying: 0, + baselineExposureUnderlyingSeconds: 0, + baselineExposureWeightUsdSeconds: 0, + realizedBaselineUnderlying: 0, + realizedGrowthUnderlying: 0, + realizedBaselineWeightUsd: 0, + realizedGrowthWeightUsd: 0, + realizedGrowthWeightEth: 0, + unmatchedExitShares: ZERO, + unmatchedExitCount: 0, + receiptCount: 0, + exitCount: 0, + deposits: 0, + withdrawals: 0, + transfersIn: 0, + transfersOut: 0, + missingPps: false, + missingReceiptPrice: false, + missingReceiptEthPrice: false, + lastAccruedTimestamp: null + } +} + +function formatAmount(value: bigint, decimals: number): number { + return parseFloat(formatUnits(value, decimals)) +} + +function scaleNumber(value: number, numerator: bigint, denominator: bigint): number { + return denominator === ZERO ? 0 : value * (Number(numerator) / Number(denominator)) +} + +function protocolReturnPct(growth: number, baseline: number): number | null { + return baseline > 0 ? (growth / baseline) * 100 : null +} + +function annualizedProtocolReturnPct(growth: number, exposureYears: number): number | null { + return exposureYears > 0 ? (growth / exposureYears) * 100 : null +} + +function advanceGrowthIndex(args: { + previousIndex: number | null + deltaGrowthWeightUsd: number + deltaExposureWeightUsdYears: number + deltaSeconds: number + hasCapital: boolean +}): number | null { + if (args.previousIndex === null) { + return args.hasCapital ? 100 : null + } + + if (args.deltaExposureWeightUsdYears <= 0 || args.deltaSeconds <= 0) { + return args.previousIndex + } + + const intervalYears = args.deltaSeconds / SECONDS_PER_YEAR + if (intervalYears <= 0) { + return args.previousIndex + } + + const intervalReturn = (args.deltaGrowthWeightUsd * intervalYears) / args.deltaExposureWeightUsdYears + const nextIndex = args.previousIndex * (1 + intervalReturn) + return Number.isFinite(nextIndex) ? nextIndex : args.previousIndex +} + +function normalizeTokenSymbol(symbol: string | null | undefined): string { + return String(symbol ?? '') + .trim() + .toUpperCase() + .replace(/[^A-Z0-9]/g, '') +} + +function isEthFamilyMetadata(metadata: VaultMetadata | undefined): boolean { + if (!metadata || metadata.category === 'stable') { + return false + } + + return ETH_FAMILY_SYMBOLS.has(normalizeTokenSymbol(metadata.token.symbol)) +} + +function classifyOpenBaselineBucket(metadata: VaultMetadata | undefined): 'stable' | 'ethFamily' | 'other' { + if (metadata?.category === 'stable') { + return 'stable' + } + + if (isEthFamilyMetadata(metadata)) { + return 'ethFamily' + } + + return 'other' +} + +function resolveRecommendedGrowthDisplay(composition: { stable: number; ethFamily: number; other: number }): { + recommendedGrowthDisplay: TGrowthDisplay + recommendedGrowthDisplayReason: TGrowthDisplayReason +} { + const total = composition.stable + composition.ethFamily + composition.other + + if (total <= 0) { + return { + recommendedGrowthDisplay: 'index', + recommendedGrowthDisplayReason: 'mixed' + } + } + + if (composition.stable / total >= 0.9) { + return { + recommendedGrowthDisplay: 'usd', + recommendedGrowthDisplayReason: 'stable_dominant' + } + } + + if (composition.ethFamily / total >= 0.9) { + return { + recommendedGrowthDisplay: 'eth', + recommendedGrowthDisplayReason: 'eth_dominant' + } + } + + return { + recommendedGrowthDisplay: 'index', + recommendedGrowthDisplayReason: 'mixed' + } +} + +function normalizeProtocolReturnVaultFilters( + requestedVaultFilters?: TProtocolReturnVaultFilter[] | string, + legacyVaultChainId?: number +): TProtocolReturnVaultFilter[] | undefined { + if (typeof requestedVaultFilters === 'string') { + return Number.isInteger(legacyVaultChainId) + ? [{ chainId: Number(legacyVaultChainId), vaultAddress: lowerCaseAddress(requestedVaultFilters) }] + : undefined + } + + return requestedVaultFilters?.map((vault) => ({ + chainId: Number(vault.chainId), + vaultAddress: lowerCaseAddress(vault.vaultAddress) + })) +} + +function filterEventsByRequestedVaults( + events: TRawPnlEvent[], + requestedVaults?: TProtocolReturnVaultFilter[] +): TRawPnlEvent[] { + if (!requestedVaults?.length) { + return events + } + + const requestedVaultKeys = new Set( + requestedVaults.map((vault) => toVaultKey(vault.chainId, lowerCaseAddress(vault.vaultAddress))) + ) + + return events.filter((event) => requestedVaultKeys.has(toVaultKey(event.chainId, event.familyVaultAddress))) +} + +function filterVaultIdentifiersByRequestedVaults( + vaults: TProtocolReturnVaultFilter[], + requestedVaults?: TProtocolReturnVaultFilter[] +): TProtocolReturnVaultFilter[] { + if (!requestedVaults?.length) { + return vaults + } + + const requestedVaultKeys = new Set( + requestedVaults.map((vault) => toVaultKey(vault.chainId, lowerCaseAddress(vault.vaultAddress))) + ) + return vaults.filter((vault) => requestedVaultKeys.has(toVaultKey(vault.chainId, vault.vaultAddress))) +} +function tokenPriceMapKey(metadata: VaultMetadata): string { + return `${getChainPrefix(metadata.chainId)}:${metadata.token.address.toLowerCase()}` +} + +function getReceiptPriceBucketTimestamps(timestamp: number, currentTimestamp: number): number[] { + const dayStart = Math.floor(timestamp / RECEIPT_PRICE_BUCKET_SECONDS) * RECEIPT_PRICE_BUCKET_SECONDS + const nextDayStart = dayStart + RECEIPT_PRICE_BUCKET_SECONDS + + return [dayStart, ...(nextDayStart <= currentTimestamp ? [nextDayStart] : [])] +} + +function isReceiptEvent(event: TRawPnlEvent, userAddress: string): boolean { + return event.kind === 'deposit' || (event.kind === 'transfer' && event.receiver === userAddress) +} + +export function buildReceiptPriceRequests(args: { + events: TRawPnlEvent[] + metadata: Map + userAddress: string + currentTimestamp: number +}): TSimpleReceiptPriceRequest[] { + const userAddress = lowerCaseAddress(args.userAddress) + return Array.from( + args.events + .filter((event) => isReceiptEvent(event, userAddress)) + .reduce }>>((requests, event) => { + const metadata = args.metadata.get(toVaultKey(event.chainId, event.familyVaultAddress)) + + if (!metadata) { + return requests + } + + const tokenKey = tokenPriceMapKey(metadata) + const request = requests.get(tokenKey) ?? { + chainId: metadata.chainId, + address: metadata.token.address, + timestamps: new Set() + } + + getReceiptPriceBucketTimestamps(event.blockTimestamp, args.currentTimestamp).forEach((timestamp) => { + request.timestamps.add(timestamp) + }) + requests.set(tokenKey, request) + return requests + }, new Map()) + .values() + ).map((request) => ({ + chainId: request.chainId, + address: request.address, + timestamps: Array.from(request.timestamps).sort((a, b) => a - b) + })) +} + +function countReceiptPricePoints(requests: TSimpleReceiptPriceRequest[]): number { + return requests.reduce((total, request) => total + request.timestamps.length, 0) +} + +async function fetchReceiptPrices(requests: TSimpleReceiptPriceRequest[]): Promise>> { + if (requests.length === 0) { + return new Map() + } + + try { + return await fetchHistoricalPricesForTokenTimestamps(requests, { resolution: 'utc_day' }) + } catch (error) { + debugError('pnl-simple', 'receipt price fetch failed, continuing with missing receipt prices', error, { + tokens: requests.length, + pricePoints: countReceiptPricePoints(requests) + }) + return new Map() + } +} + +async function fetchEthReceiptPrices(timestamps: number[]): Promise> { + if (timestamps.length === 0) { + return new Map() + } + + try { + const priceData = await fetchHistoricalPricesForTokenTimestamps( + [{ chainId: 1, address: ETHEREUM_WETH_ADDRESS, timestamps }], + { resolution: 'utc_day' } + ) + return priceData.get(ETHEREUM_WETH_PRICE_KEY) ?? new Map() + } catch (error) { + debugError('pnl-simple', 'eth receipt price fetch failed, continuing with missing eth receipt price', error, { + timestamps: timestamps.length + }) + return new Map() + } +} + +function getReceiptPriceUsd( + metadata: VaultMetadata | undefined, + priceData: Map>, + timestamp: number +): number { + if (!metadata) { + return 0 + } + + const priceMap = priceData.get(tokenPriceMapKey(metadata)) + return priceMap ? getPriceAtTimestamp(priceMap, timestamp) : 0 +} + +function getReceiptPriceEth(ethPriceData: Map, receiptPriceUsd: number, timestamp: number): number { + if (receiptPriceUsd <= 0) { + return 0 + } + + const ethPriceUsd = getPriceAtTimestamp(ethPriceData, timestamp) + return ethPriceUsd > 0 ? receiptPriceUsd / ethPriceUsd : 0 +} + +function getEventPps(ppsMap: Map | undefined, timestamp: number): number | null { + return ppsMap ? getPPS(ppsMap, timestamp) : null +} + +function isKnownStakingWrapperEvent(event: TRawPnlEvent): boolean { + const stakingVaultAddress = getStakingVaultAddress(event.chainId, event.familyVaultAddress) + return stakingVaultAddress !== null && lowerCaseAddress(event.vaultAddress) === stakingVaultAddress +} + +function valueDepositOrWithdrawalEvent( + event: Extract, + args: { + assetDecimals: number + shareDecimals: number + ppsMap: Map | undefined + } +): { + underlying: number + missingPps: boolean +} { + if (!isKnownStakingWrapperEvent(event)) { + return { + underlying: formatAmount(event.assets, args.assetDecimals), + missingPps: false + } + } + + const pps = getEventPps(args.ppsMap, event.blockTimestamp) + if (pps === null) { + return { + underlying: formatAmount(event.assets, args.assetDecimals), + missingPps: true + } + } + + return { + underlying: formatAmount(event.shares, args.shareDecimals) * pps, + missingPps: false + } +} + +function addReceipt( + ledger: TProtocolReturnLedger, + args: { + shares: bigint + baselineUnderlying: number + receiptTimestamp: number + receiptPriceUsd: number + receiptPriceEth: number + receiptKind: TProtocolReturnReceiptKind + transactionHash: string + } +): TProtocolReturnLedger { + if (args.shares <= ZERO) { + return ledger + } + + return { + ...ledger, + lots: [ + ...ledger.lots, + { + shares: args.shares, + baselineUnderlying: args.baselineUnderlying, + receiptTimestamp: args.receiptTimestamp, + receiptPriceUsd: args.receiptPriceUsd, + receiptPriceEth: args.receiptPriceEth, + receiptPriceMissing: args.receiptPriceUsd <= 0, + receiptPriceEthMissing: args.receiptPriceEth <= 0, + receiptKind: args.receiptKind, + transactionHash: args.transactionHash + } + ], + baselineUnderlying: ledger.baselineUnderlying + args.baselineUnderlying, + receiptCount: ledger.receiptCount + 1, + deposits: ledger.deposits + (args.receiptKind === 'deposit' ? 1 : 0), + transfersIn: ledger.transfersIn + (args.receiptKind === 'transfer_in' ? 1 : 0), + missingReceiptPrice: ledger.missingReceiptPrice || args.receiptPriceUsd <= 0, + missingReceiptEthPrice: ledger.missingReceiptEthPrice || args.receiptPriceEth <= 0 + } +} + +function getOutstandingBaselineUnderlying(lots: TProtocolReturnLot[]): number { + return lots.reduce((total, lot) => total + lot.baselineUnderlying, 0) +} + +function getOutstandingBaselineWeightUsd(lots: TProtocolReturnLot[]): number { + return lots.reduce((total, lot) => total + lot.baselineUnderlying * lot.receiptPriceUsd, 0) +} + +function getOutstandingBaselineWeightEth(lots: TProtocolReturnLot[]): number { + return lots.reduce((total, lot) => total + lot.baselineUnderlying * lot.receiptPriceEth, 0) +} + +function accrueLedgerExposure(ledger: TProtocolReturnLedger, nextTimestamp: number): TProtocolReturnLedger { + if (ledger.lastAccruedTimestamp === null) { + return { + ...ledger, + lastAccruedTimestamp: nextTimestamp + } + } + + const elapsedSeconds = Math.max(0, nextTimestamp - ledger.lastAccruedTimestamp) + if (elapsedSeconds === 0) { + return ledger + } + + return { + ...ledger, + baselineExposureUnderlyingSeconds: + ledger.baselineExposureUnderlyingSeconds + getOutstandingBaselineUnderlying(ledger.lots) * elapsedSeconds, + baselineExposureWeightUsdSeconds: + ledger.baselineExposureWeightUsdSeconds + getOutstandingBaselineWeightUsd(ledger.lots) * elapsedSeconds, + lastAccruedTimestamp: nextTimestamp + } +} + +function splitLot(lot: TProtocolReturnLot, shares: bigint): TProtocolReturnConsumedLot { + return { + shares, + baselineUnderlying: scaleNumber(lot.baselineUnderlying, shares, lot.shares), + receiptTimestamp: lot.receiptTimestamp, + receiptPriceUsd: lot.receiptPriceUsd, + receiptPriceEth: lot.receiptPriceEth, + receiptPriceMissing: lot.receiptPriceMissing, + receiptPriceEthMissing: lot.receiptPriceEthMissing + } +} + +function consumeLots( + lots: TProtocolReturnLot[], + shares: bigint +): { + consumedLots: TProtocolReturnConsumedLot[] + remainingLots: TProtocolReturnLot[] + consumedShares: bigint +} { + const consumed = lots.reduce<{ + sharesToConsume: bigint + consumedLots: TProtocolReturnConsumedLot[] + remainingLots: TProtocolReturnLot[] + consumedShares: bigint + }>( + (state, lot) => { + if (state.sharesToConsume <= ZERO) { + state.remainingLots.push(lot) + return state + } + + if (lot.shares <= state.sharesToConsume) { + state.sharesToConsume -= lot.shares + state.consumedLots.push({ + shares: lot.shares, + baselineUnderlying: lot.baselineUnderlying, + receiptTimestamp: lot.receiptTimestamp, + receiptPriceUsd: lot.receiptPriceUsd, + receiptPriceEth: lot.receiptPriceEth, + receiptPriceMissing: lot.receiptPriceMissing, + receiptPriceEthMissing: lot.receiptPriceEthMissing + }) + state.consumedShares += lot.shares + return state + } + + const remainingShares = lot.shares - state.sharesToConsume + const consumedLot = splitLot(lot, state.sharesToConsume) + const remainingLot: TProtocolReturnLot = { + ...lot, + shares: remainingShares, + baselineUnderlying: scaleNumber(lot.baselineUnderlying, remainingShares, lot.shares) + } + + state.sharesToConsume = ZERO + state.consumedLots.push(consumedLot) + state.remainingLots.push(remainingLot) + state.consumedShares += consumedLot.shares + return state + }, + { + sharesToConsume: shares, + consumedLots: [], + remainingLots: [], + consumedShares: ZERO + } + ) + + return { + consumedLots: consumed.consumedLots, + remainingLots: consumed.remainingLots, + consumedShares: consumed.consumedShares + } +} + +function addExit( + ledger: TProtocolReturnLedger, + args: { + shares: bigint + exitUnderlying: number + exitKind: TProtocolReturnExitKind + } +): TProtocolReturnLedger { + if (args.shares <= ZERO) { + return ledger + } + + const consumed = consumeLots(ledger.lots, args.shares) + const matchedExitUnderlying = scaleNumber(args.exitUnderlying, consumed.consumedShares, args.shares) + const consumedBaselineUnderlying = consumed.consumedLots.reduce((total, lot) => total + lot.baselineUnderlying, 0) + const consumedBaselineWeightUsd = consumed.consumedLots.reduce( + (total, lot) => total + lot.baselineUnderlying * lot.receiptPriceUsd, + 0 + ) + const consumedExitWeightUsd = consumed.consumedLots.reduce( + (total, lot) => + total + scaleNumber(matchedExitUnderlying, lot.shares, consumed.consumedShares) * lot.receiptPriceUsd, + 0 + ) + const consumedExitWeightEth = consumed.consumedLots.reduce( + (total, lot) => + total + scaleNumber(matchedExitUnderlying, lot.shares, consumed.consumedShares) * lot.receiptPriceEth, + 0 + ) + const unmatchedExitShares = args.shares - consumed.consumedShares + + return { + ...ledger, + lots: consumed.remainingLots, + realizedBaselineUnderlying: ledger.realizedBaselineUnderlying + consumedBaselineUnderlying, + realizedGrowthUnderlying: ledger.realizedGrowthUnderlying + (matchedExitUnderlying - consumedBaselineUnderlying), + realizedBaselineWeightUsd: ledger.realizedBaselineWeightUsd + consumedBaselineWeightUsd, + realizedGrowthWeightUsd: ledger.realizedGrowthWeightUsd + (consumedExitWeightUsd - consumedBaselineWeightUsd), + realizedGrowthWeightEth: + ledger.realizedGrowthWeightEth + + (consumedExitWeightEth - + consumed.consumedLots.reduce((total, lot) => total + lot.baselineUnderlying * lot.receiptPriceEth, 0)), + unmatchedExitShares: ledger.unmatchedExitShares + unmatchedExitShares, + unmatchedExitCount: ledger.unmatchedExitCount + (unmatchedExitShares > ZERO ? 1 : 0), + exitCount: ledger.exitCount + 1, + withdrawals: ledger.withdrawals + (args.exitKind === 'withdrawal' ? 1 : 0), + transfersOut: ledger.transfersOut + (args.exitKind === 'transfer_out' ? 1 : 0), + missingReceiptEthPrice: + ledger.missingReceiptEthPrice || consumed.consumedLots.some((lot) => lot.receiptPriceEthMissing) + } +} + +function eventSortKey(event: TRawPnlEvent): string { + return [ + String(event.blockTimestamp).padStart(12, '0'), + String(event.blockNumber).padStart(12, '0'), + String(event.logIndex).padStart(8, '0'), + event.id + ].join(':') +} + +function sortEvents(events: TRawPnlEvent[]): TRawPnlEvent[] { + return [...events].sort((a, b) => eventSortKey(a).localeCompare(eventSortKey(b))) +} + +function processEvent( + ledgers: Map, + event: TRawPnlEvent, + args: { + userAddress: string + metadata: Map + ppsData: Map> + priceData: Map> + ethPriceData: Map + } +): Map { + const vaultKey = toVaultKey(event.chainId, event.familyVaultAddress) + const metadata = args.metadata.get(vaultKey) + const assetDecimals = metadata?.token.decimals ?? 18 + const shareDecimals = metadata?.decimals ?? 18 + const ppsMap = args.ppsData.get(vaultKey) + const currentLedger = accrueLedgerExposure( + ledgers.get(vaultKey) ?? emptyLedger(event.chainId, event.familyVaultAddress), + event.blockTimestamp + ) + + if (event.kind === 'deposit') { + const receiptPriceUsd = getReceiptPriceUsd(metadata, args.priceData, event.blockTimestamp) + const receiptPriceEth = getReceiptPriceEth(args.ethPriceData, receiptPriceUsd, event.blockTimestamp) + const valuation = valueDepositOrWithdrawalEvent(event, { + assetDecimals, + shareDecimals, + ppsMap + }) + ledgers.set( + vaultKey, + addReceipt(valuation.missingPps ? { ...currentLedger, missingPps: true } : currentLedger, { + shares: event.shares, + baselineUnderlying: valuation.underlying, + receiptTimestamp: event.blockTimestamp, + receiptPriceUsd, + receiptPriceEth, + receiptKind: 'deposit', + transactionHash: event.transactionHash + }) + ) + return ledgers + } + + if (event.kind === 'withdrawal') { + const valuation = valueDepositOrWithdrawalEvent(event, { + assetDecimals, + shareDecimals, + ppsMap + }) + ledgers.set( + vaultKey, + addExit(valuation.missingPps ? { ...currentLedger, missingPps: true } : currentLedger, { + shares: event.shares, + exitUnderlying: valuation.underlying, + exitKind: 'withdrawal' + }) + ) + return ledgers + } + + if (event.sender === args.userAddress && event.receiver === args.userAddress) { + ledgers.set(vaultKey, currentLedger) + return ledgers + } + + const pps = getEventPps(ppsMap, event.blockTimestamp) + if (pps === null) { + ledgers.set(vaultKey, { ...currentLedger, missingPps: true }) + return ledgers + } + + if (event.receiver === args.userAddress) { + const receiptPriceUsd = getReceiptPriceUsd(metadata, args.priceData, event.blockTimestamp) + const receiptPriceEth = getReceiptPriceEth(args.ethPriceData, receiptPriceUsd, event.blockTimestamp) + ledgers.set( + vaultKey, + addReceipt(currentLedger, { + shares: event.shares, + baselineUnderlying: formatAmount(event.shares, shareDecimals) * pps, + receiptTimestamp: event.blockTimestamp, + receiptPriceUsd, + receiptPriceEth, + receiptKind: 'transfer_in', + transactionHash: event.transactionHash + }) + ) + return ledgers + } + + if (event.sender === args.userAddress) { + ledgers.set( + vaultKey, + addExit(currentLedger, { + shares: event.shares, + exitUnderlying: formatAmount(event.shares, shareDecimals) * pps, + exitKind: 'transfer_out' + }) + ) + return ledgers + } + + ledgers.set(vaultKey, currentLedger) + return ledgers +} + +function groupEventsByTransaction(events: TRawPnlEvent[]): TRawPnlEvent[][] { + return Array.from( + sortEvents(events).reduce>((grouped, event) => { + const transactionKey = `${event.chainId}:${event.transactionHash}` + const bucket = grouped.get(transactionKey) ?? [] + bucket.push(event) + grouped.set(transactionKey, bucket) + return grouped + }, new Map()) + ).map(([, txEvents]) => txEvents) +} + +function groupTransactionEventsByFamily(txEvents: TRawPnlEvent[]): TRawPnlEvent[][] { + return Array.from( + txEvents.reduce>((grouped, event) => { + const familyKey = toVaultKey(event.chainId, event.familyVaultAddress) + const bucket = grouped.get(familyKey) ?? [] + bucket.push(event) + grouped.set(familyKey, bucket) + return grouped + }, new Map()) + ).map(([, familyEvents]) => familyEvents) +} + +function minBigInt(left: bigint, right: bigint): bigint { + return left < right ? left : right +} + +function scaleBigInt(value: bigint, numerator: bigint, denominator: bigint): bigint { + if (value <= ZERO || numerator <= ZERO || denominator <= ZERO) { + return ZERO + } + + return (value * numerator) / denominator +} + +function cloneEventWithShares( + event: Extract, + shares: bigint +): Extract +function cloneEventWithShares( + event: Extract, + shares: bigint +): Extract +function cloneEventWithShares(event: TRawPnlEvent, shares: bigint): TRawPnlEvent +function cloneEventWithShares(event: TRawPnlEvent, shares: bigint): TRawPnlEvent { + if (event.kind === 'deposit' || event.kind === 'withdrawal') { + return { + ...event, + shares, + assets: scaleBigInt(event.assets, shares, event.shares) + } + } + + return { + ...event, + shares + } +} + +function cloneEventWithAdjustedAssets( + event: Extract, + assets: bigint, + idSuffix: string +): TRawPnlEvent { + return { + ...event, + id: `${event.id}:${idSuffix}`, + assets + } +} + +function splitAssetValuedEvent( + event: Extract, + matchedShares: bigint, + matchedAssets: bigint, + idPrefix: string +): TRawPnlEvent[] { + if (matchedShares <= ZERO || matchedAssets <= ZERO) { + return [event] + } + + if (matchedShares >= event.shares) { + return [cloneEventWithAdjustedAssets(event, matchedAssets, `${idPrefix}-matched`)] + } + + const remainderShares = event.shares - matchedShares + return [ + { + ...cloneEventWithShares(event, matchedShares), + id: `${event.id}:${idPrefix}-matched`, + assets: matchedAssets + }, + { + ...cloneEventWithShares(event, remainderShares), + id: `${event.id}:${idPrefix}-remainder` + } + ] +} + +function allocateAssetPoolAcrossEvents( + events: Array>, + totalMatchedShares: bigint, + totalMatchedAssets: bigint, + idPrefix: string +): TRawPnlEvent[] { + if (events.length === 0 || totalMatchedShares <= ZERO || totalMatchedAssets <= ZERO) { + return events + } + + let remainingMatchedShares = totalMatchedShares + let remainingMatchedAssets = totalMatchedAssets + + return events.flatMap((event, index) => { + if (remainingMatchedShares <= ZERO || remainingMatchedAssets <= ZERO) { + return [event] + } + + const matchedShares = minBigInt(event.shares, remainingMatchedShares) + if (matchedShares <= ZERO) { + return [event] + } + + const matchedAssets = + index === events.length - 1 || matchedShares === remainingMatchedShares + ? remainingMatchedAssets + : scaleBigInt(remainingMatchedAssets, matchedShares, remainingMatchedShares) + + remainingMatchedShares -= matchedShares + remainingMatchedAssets -= matchedAssets + + return splitAssetValuedEvent(event, matchedShares, matchedAssets, idPrefix) + }) +} + +function splitTransferIntoWithdrawalEvents( + event: Extract, + matchedShares: bigint, + matchedAssets: bigint, + idPrefix: string, + userAddress: string +): TRawPnlEvent[] { + if (matchedShares <= ZERO || matchedAssets <= ZERO) { + return [event] + } + + const matchedWithdrawal: Extract = { + kind: 'withdrawal', + id: `${event.id}:${idPrefix}-matched`, + chainId: event.chainId, + vaultAddress: event.vaultAddress, + familyVaultAddress: event.familyVaultAddress, + isStakingVault: event.isStakingVault, + blockNumber: event.blockNumber, + blockTimestamp: event.blockTimestamp, + logIndex: event.logIndex, + transactionHash: event.transactionHash, + transactionFrom: event.transactionFrom, + owner: userAddress, + shares: matchedShares, + assets: matchedAssets, + scopes: event.scopes + } + + if (matchedShares >= event.shares) { + return [matchedWithdrawal] + } + + return [ + matchedWithdrawal, + { + ...cloneEventWithShares(event, event.shares - matchedShares), + id: `${event.id}:${idPrefix}-remainder` + } + ] +} + +function allocateWithdrawalPoolAcrossTransferEvents( + events: Array>, + totalMatchedShares: bigint, + totalMatchedAssets: bigint, + idPrefix: string, + userAddress: string +): TRawPnlEvent[] { + if (events.length === 0 || totalMatchedShares <= ZERO || totalMatchedAssets <= ZERO) { + return events + } + + let remainingMatchedShares = totalMatchedShares + let remainingMatchedAssets = totalMatchedAssets + + return events.flatMap((event, index) => { + if (remainingMatchedShares <= ZERO || remainingMatchedAssets <= ZERO) { + return [event] + } + + const matchedShares = minBigInt(event.shares, remainingMatchedShares) + if (matchedShares <= ZERO) { + return [event] + } + + const matchedAssets = + index === events.length - 1 || matchedShares === remainingMatchedShares + ? remainingMatchedAssets + : scaleBigInt(remainingMatchedAssets, matchedShares, remainingMatchedShares) + + remainingMatchedShares -= matchedShares + remainingMatchedAssets -= matchedAssets + + return splitTransferIntoWithdrawalEvents(event, matchedShares, matchedAssets, idPrefix, userAddress) + }) +} + +function buildEffectiveSimpleFamilyEvents(txFamilyEvents: TRawPnlEvent[], userAddress: string): TRawPnlEvent[] { + const firstEvent = txFamilyEvents[0] + if (!firstEvent) { + return [] + } + + const stakingVaultAddress = getStakingVaultAddress(firstEvent.chainId, firstEvent.familyVaultAddress) + const normalizedUserAddress = lowerCaseAddress(userAddress) + let addressEvents = sortEvents(txFamilyEvents.filter((event) => event.scopes.address)) + + const addressScopedMintTransfers = addressEvents.filter( + (event): event is Extract => + event.kind === 'transfer' && + lowerCaseAddress(event.sender) === ZERO_ADDRESS && + lowerCaseAddress(event.receiver) === normalizedUserAddress && + lowerCaseAddress(event.vaultAddress) === lowerCaseAddress(firstEvent.familyVaultAddress) + ) + const sameVaultDeposits = txFamilyEvents.filter( + (event): event is Extract => + event.kind === 'deposit' && + lowerCaseAddress(event.vaultAddress) === lowerCaseAddress(firstEvent.familyVaultAddress) + ) + const matchedDirectDepositShares = minBigInt( + addressScopedMintTransfers.reduce((total, event) => total + event.shares, ZERO), + sameVaultDeposits.reduce((total, event) => total + event.shares, ZERO) + ) + + addressEvents = stripMatchedShares( + addressEvents, + (event) => + event.kind === 'transfer' && + lowerCaseAddress(event.sender) === ZERO_ADDRESS && + lowerCaseAddress(event.receiver) === normalizedUserAddress && + lowerCaseAddress(event.vaultAddress) === lowerCaseAddress(firstEvent.familyVaultAddress), + matchedDirectDepositShares + ) + + const addressScopedBurnTransfers = addressEvents.filter( + (event): event is Extract => + event.kind === 'transfer' && + lowerCaseAddress(event.sender) === normalizedUserAddress && + lowerCaseAddress(event.receiver) === ZERO_ADDRESS && + lowerCaseAddress(event.vaultAddress) === lowerCaseAddress(firstEvent.familyVaultAddress) + ) + const sameVaultWithdrawals = txFamilyEvents.filter( + (event): event is Extract => + event.kind === 'withdrawal' && + lowerCaseAddress(event.vaultAddress) === lowerCaseAddress(firstEvent.familyVaultAddress) + ) + const matchedDirectWithdrawalShares = minBigInt( + addressScopedBurnTransfers.reduce((total, event) => total + event.shares, ZERO), + sameVaultWithdrawals.reduce((total, event) => total + event.shares, ZERO) + ) + + addressEvents = stripMatchedShares( + addressEvents, + (event) => + event.kind === 'transfer' && + lowerCaseAddress(event.sender) === normalizedUserAddress && + lowerCaseAddress(event.receiver) === ZERO_ADDRESS && + lowerCaseAddress(event.vaultAddress) === lowerCaseAddress(firstEvent.familyVaultAddress), + matchedDirectWithdrawalShares + ) + + const addressScopedTransferIns = addressEvents.filter( + (event): event is Extract => + event.kind === 'transfer' && + lowerCaseAddress(event.sender) !== ZERO_ADDRESS && + lowerCaseAddress(event.sender) !== normalizedUserAddress && + lowerCaseAddress(event.receiver) === normalizedUserAddress && + lowerCaseAddress(event.vaultAddress) === lowerCaseAddress(firstEvent.familyVaultAddress) + ) + const addressScopedWithdrawals = addressEvents.filter( + (event): event is Extract => + event.kind === 'withdrawal' && + !event.isStakingVault && + lowerCaseAddress(event.owner) === normalizedUserAddress && + lowerCaseAddress(event.vaultAddress) === lowerCaseAddress(firstEvent.familyVaultAddress) + ) + const matchedSameTxTransferInWithdrawalShares = minBigInt( + addressScopedTransferIns.reduce((total, event) => total + event.shares, ZERO), + addressScopedWithdrawals.reduce((total, event) => total + event.shares, ZERO) + ) + + addressEvents = stripMatchedShares( + addressEvents, + (event) => + event.kind === 'transfer' && + lowerCaseAddress(event.sender) !== ZERO_ADDRESS && + lowerCaseAddress(event.sender) !== normalizedUserAddress && + lowerCaseAddress(event.receiver) === normalizedUserAddress && + lowerCaseAddress(event.vaultAddress) === lowerCaseAddress(firstEvent.familyVaultAddress), + matchedSameTxTransferInWithdrawalShares + ) + addressEvents = stripMatchedShares( + addressEvents, + (event) => + event.kind === 'withdrawal' && + !event.isStakingVault && + lowerCaseAddress(event.owner) === normalizedUserAddress && + lowerCaseAddress(event.vaultAddress) === lowerCaseAddress(firstEvent.familyVaultAddress), + matchedSameTxTransferInWithdrawalShares + ) + + const addressScopedTransferOuts = addressEvents.filter( + (event): event is Extract => + event.kind === 'transfer' && + lowerCaseAddress(event.sender) === normalizedUserAddress && + lowerCaseAddress(event.receiver) !== normalizedUserAddress + ) + const txScopedUnderlyingWithdrawals = txFamilyEvents.filter( + (event): event is Extract => + event.kind === 'withdrawal' && + !event.isStakingVault && + !event.scopes.address && + lowerCaseAddress(event.vaultAddress) === lowerCaseAddress(firstEvent.familyVaultAddress) + ) + const matchedIntermediaryExitShares = minBigInt( + addressScopedTransferOuts.reduce((total, event) => total + event.shares, ZERO), + txScopedUnderlyingWithdrawals.reduce((total, event) => total + event.shares, ZERO) + ) + const matchedIntermediaryExitAssets = scaleBigInt( + txScopedUnderlyingWithdrawals.reduce((total, event) => total + event.assets, ZERO), + matchedIntermediaryExitShares, + txScopedUnderlyingWithdrawals.reduce((total, event) => total + event.shares, ZERO) + ) + + if (matchedIntermediaryExitShares > ZERO && matchedIntermediaryExitAssets > ZERO) { + const adjustedTransferOutIds = new Set(addressScopedTransferOuts.map((event) => event.id)) + const adjustedExits = allocateWithdrawalPoolAcrossTransferEvents( + addressScopedTransferOuts, + matchedIntermediaryExitShares, + matchedIntermediaryExitAssets, + 'intermediary-exit', + normalizedUserAddress + ) + + addressEvents = sortEvents([ + ...addressEvents.filter((event) => !adjustedTransferOutIds.has(event.id)), + ...adjustedExits + ]) + } + + if (!stakingVaultAddress) { + return addressEvents + } + + const addressScopedStakingMintTransfers = addressEvents.filter( + (event): event is Extract => + event.kind === 'transfer' && + event.isStakingVault && + lowerCaseAddress(event.sender) === ZERO_ADDRESS && + lowerCaseAddress(event.receiver) === normalizedUserAddress && + lowerCaseAddress(event.vaultAddress) === stakingVaultAddress + ) + const addressScopedStakingDepositReceipts = addressEvents.filter( + (event): event is Extract => + event.kind === 'deposit' && + event.isStakingVault && + lowerCaseAddress(event.vaultAddress) === stakingVaultAddress && + lowerCaseAddress(event.owner) === normalizedUserAddress + ) + const matchedStakingDepositMintShares = minBigInt( + addressScopedStakingMintTransfers.reduce((total, event) => total + event.shares, ZERO), + addressScopedStakingDepositReceipts.reduce((total, event) => total + event.shares, ZERO) + ) + + addressEvents = stripMatchedShares( + addressEvents, + (event) => + event.kind === 'transfer' && + event.isStakingVault && + lowerCaseAddress(event.sender) === ZERO_ADDRESS && + lowerCaseAddress(event.receiver) === normalizedUserAddress && + lowerCaseAddress(event.vaultAddress) === stakingVaultAddress, + matchedStakingDepositMintShares + ) + + const addressScopedStakingBurnTransfers = addressEvents.filter( + (event): event is Extract => + event.kind === 'transfer' && + event.isStakingVault && + lowerCaseAddress(event.sender) === normalizedUserAddress && + lowerCaseAddress(event.receiver) === ZERO_ADDRESS && + lowerCaseAddress(event.vaultAddress) === stakingVaultAddress + ) + const addressScopedStakingWithdrawalReceipts = addressEvents.filter( + (event): event is Extract => + event.kind === 'withdrawal' && + event.isStakingVault && + lowerCaseAddress(event.vaultAddress) === stakingVaultAddress && + lowerCaseAddress(event.owner) === normalizedUserAddress + ) + const matchedStakingWithdrawalBurnShares = minBigInt( + addressScopedStakingBurnTransfers.reduce((total, event) => total + event.shares, ZERO), + addressScopedStakingWithdrawalReceipts.reduce((total, event) => total + event.shares, ZERO) + ) + + addressEvents = stripMatchedShares( + addressEvents, + (event) => + event.kind === 'transfer' && + event.isStakingVault && + lowerCaseAddress(event.sender) === normalizedUserAddress && + lowerCaseAddress(event.receiver) === ZERO_ADDRESS && + lowerCaseAddress(event.vaultAddress) === stakingVaultAddress, + matchedStakingWithdrawalBurnShares + ) + + const underlyingDeposits = txFamilyEvents.filter( + (event): event is Extract => + event.kind === 'deposit' && + !event.isStakingVault && + lowerCaseAddress(event.vaultAddress) === lowerCaseAddress(firstEvent.familyVaultAddress) + ) + const underlyingWithdrawals = txFamilyEvents.filter( + (event): event is Extract => + event.kind === 'withdrawal' && + !event.isStakingVault && + lowerCaseAddress(event.vaultAddress) === lowerCaseAddress(firstEvent.familyVaultAddress) + ) + const underlyingTransfersToStaking = txFamilyEvents.filter( + (event): event is Extract => + event.kind === 'transfer' && + !event.isStakingVault && + lowerCaseAddress(event.vaultAddress) === lowerCaseAddress(firstEvent.familyVaultAddress) && + lowerCaseAddress(event.receiver) === stakingVaultAddress + ) + const underlyingTransfersFromStaking = txFamilyEvents.filter( + (event): event is Extract => + event.kind === 'transfer' && + !event.isStakingVault && + lowerCaseAddress(event.vaultAddress) === lowerCaseAddress(firstEvent.familyVaultAddress) && + lowerCaseAddress(event.sender) === stakingVaultAddress + ) + const addressScopedStakingDeposits = addressEvents.filter( + (event): event is Extract => + event.kind === 'deposit' && + event.isStakingVault && + lowerCaseAddress(event.vaultAddress) === stakingVaultAddress && + lowerCaseAddress(event.owner) === normalizedUserAddress + ) + const addressScopedStakingWithdrawals = addressEvents.filter( + (event): event is Extract => + event.kind === 'withdrawal' && + event.isStakingVault && + lowerCaseAddress(event.vaultAddress) === stakingVaultAddress && + lowerCaseAddress(event.owner) === normalizedUserAddress + ) + + const matchedStakeShares = minBigInt( + addressScopedStakingDeposits.reduce((total, event) => total + event.shares, ZERO), + minBigInt( + underlyingTransfersToStaking.reduce((total, event) => total + event.shares, ZERO), + underlyingDeposits.reduce((total, event) => total + event.shares, ZERO) + ) + ) + const matchedStakeAssets = scaleBigInt( + underlyingDeposits.reduce((total, event) => total + event.assets, ZERO), + matchedStakeShares, + underlyingDeposits.reduce((total, event) => total + event.shares, ZERO) + ) + + if (matchedStakeShares > ZERO && matchedStakeAssets > ZERO) { + const adjustedDepositIds = new Set(addressScopedStakingDeposits.map((event) => event.id)) + const adjustedDeposits = allocateAssetPoolAcrossEvents( + addressScopedStakingDeposits, + matchedStakeShares, + matchedStakeAssets, + 'stake-basis' + ) + + addressEvents = sortEvents([ + ...addressEvents.filter((event) => !adjustedDepositIds.has(event.id)), + ...adjustedDeposits + ]) + } + + const matchedUnstakeShares = minBigInt( + addressScopedStakingWithdrawals.reduce((total, event) => total + event.shares, ZERO), + minBigInt( + underlyingTransfersFromStaking.reduce((total, event) => total + event.shares, ZERO), + underlyingWithdrawals.reduce((total, event) => total + event.shares, ZERO) + ) + ) + const matchedUnstakeAssets = scaleBigInt( + underlyingWithdrawals.reduce((total, event) => total + event.assets, ZERO), + matchedUnstakeShares, + underlyingWithdrawals.reduce((total, event) => total + event.shares, ZERO) + ) + + if (matchedUnstakeShares > ZERO && matchedUnstakeAssets > ZERO) { + const adjustedWithdrawalIds = new Set(addressScopedStakingWithdrawals.map((event) => event.id)) + const adjustedWithdrawals = allocateAssetPoolAcrossEvents( + addressScopedStakingWithdrawals, + matchedUnstakeShares, + matchedUnstakeAssets, + 'unstake-proceeds' + ) + + addressEvents = sortEvents([ + ...addressEvents.filter((event) => !adjustedWithdrawalIds.has(event.id)), + ...adjustedWithdrawals + ]) + } + + return addressEvents +} + +function buildEffectiveSimpleEvents(events: TRawPnlEvent[], userAddress: string): TRawPnlEvent[] { + return groupEventsByTransaction(events).flatMap((txEvents) => + groupTransactionEventsByFamily(txEvents).flatMap((txFamilyEvents) => + buildEffectiveSimpleFamilyEvents(txFamilyEvents, userAddress) + ) + ) +} + +function stripMatchedShares( + events: TRawPnlEvent[], + matcher: (event: TRawPnlEvent) => boolean, + sharesToStrip: bigint +): TRawPnlEvent[] { + if (sharesToStrip <= ZERO) { + return events + } + + let remainingSharesToStrip = sharesToStrip + + return events.flatMap((event) => { + if (!matcher(event) || remainingSharesToStrip <= ZERO) { + return [event] + } + + const strippedShares = minBigInt(event.shares, remainingSharesToStrip) + remainingSharesToStrip -= strippedShares + + if (strippedShares === event.shares) { + return [] + } + + return [cloneEventWithShares(event, event.shares - strippedShares)] + }) +} + +function normalizeStakingWrapperEvents(txFamilyEvents: TRawPnlEvent[], userAddress: string): TRawPnlEvent[] { + const firstEvent = txFamilyEvents[0] + if (!firstEvent) { + return txFamilyEvents + } + + const familyVaultAddress = lowerCaseAddress(firstEvent.familyVaultAddress) + const stakingVaultAddress = getStakingVaultAddress(firstEvent.chainId, firstEvent.familyVaultAddress) + if (!stakingVaultAddress) { + return txFamilyEvents + } + + const normalizedUserAddress = lowerCaseAddress(userAddress) + let remainingEvents = [...txFamilyEvents] + + const unstakeWithdrawalShares = remainingEvents.reduce( + (total, event) => + event.kind === 'withdrawal' && + event.isStakingVault && + lowerCaseAddress(event.vaultAddress) === stakingVaultAddress + ? total + event.shares + : total, + ZERO + ) + const unstakeTransferInShares = remainingEvents.reduce( + (total, event) => + event.kind === 'transfer' && + !event.isStakingVault && + lowerCaseAddress(event.vaultAddress) === familyVaultAddress && + lowerCaseAddress(event.sender) === stakingVaultAddress && + lowerCaseAddress(event.receiver) === normalizedUserAddress + ? total + event.shares + : total, + ZERO + ) + const matchedUnstakeShares = minBigInt(unstakeWithdrawalShares, unstakeTransferInShares) + + remainingEvents = stripMatchedShares( + remainingEvents, + (event) => + event.kind === 'withdrawal' && + event.isStakingVault && + lowerCaseAddress(event.vaultAddress) === stakingVaultAddress, + matchedUnstakeShares + ) + remainingEvents = stripMatchedShares( + remainingEvents, + (event) => + event.kind === 'transfer' && + !event.isStakingVault && + lowerCaseAddress(event.vaultAddress) === familyVaultAddress && + lowerCaseAddress(event.sender) === stakingVaultAddress && + lowerCaseAddress(event.receiver) === normalizedUserAddress, + matchedUnstakeShares + ) + + const stakeTransferOutShares = remainingEvents.reduce( + (total, event) => + event.kind === 'transfer' && + !event.isStakingVault && + lowerCaseAddress(event.vaultAddress) === familyVaultAddress && + lowerCaseAddress(event.sender) === normalizedUserAddress && + lowerCaseAddress(event.receiver) === stakingVaultAddress + ? total + event.shares + : total, + ZERO + ) + const stakeDepositShares = remainingEvents.reduce( + (total, event) => + event.kind === 'deposit' && event.isStakingVault && lowerCaseAddress(event.vaultAddress) === stakingVaultAddress + ? total + event.shares + : total, + ZERO + ) + const matchedStakeShares = minBigInt(stakeTransferOutShares, stakeDepositShares) + + remainingEvents = stripMatchedShares( + remainingEvents, + (event) => + event.kind === 'transfer' && + !event.isStakingVault && + lowerCaseAddress(event.vaultAddress) === familyVaultAddress && + lowerCaseAddress(event.sender) === normalizedUserAddress && + lowerCaseAddress(event.receiver) === stakingVaultAddress, + matchedStakeShares + ) + remainingEvents = stripMatchedShares( + remainingEvents, + (event) => + event.kind === 'deposit' && event.isStakingVault && lowerCaseAddress(event.vaultAddress) === stakingVaultAddress, + matchedStakeShares + ) + + return remainingEvents +} + +function getProtocolReturnTimestamps(events: TRawPnlEvent[], timeframe: '1y' | 'all'): number[] { + if (timeframe === '1y') { + return generateDailyTimestamps(holdingsConfig.historyDays, 1).map((timestamp) => toSettledDayTimestamp(timestamp)) + } + + if (events.length === 0) { + return [] + } + + const settledTimestamps = generateDailyTimestamps(holdingsConfig.historyDays, 1) + const latestSettledTimestamp = settledTimestamps[settledTimestamps.length - 1] ?? 0 + return generateDailyTimestampsFromRange(holdingsConfig.historyStartTimestamp, latestSettledTimestamp).map( + (timestamp) => toSettledDayTimestamp(timestamp) + ) +} + +function buildTransactionHashesByChain(events: TRawPnlEvent[]): Map { + return events.reduce>((grouped, event) => { + const transactionHash = lowerCaseAddress(event.transactionHash) + const existing = grouped.get(event.chainId) ?? [] + + if (existing.includes(transactionHash)) { + return grouped + } + + grouped.set(event.chainId, [...existing, transactionHash]) + return grouped + }, new Map()) +} + +async function enrichSimpleHistoryRawEvents(args: { + events: TRawPnlEvent[] + version: VaultVersion + maxTimestamp: number +}): Promise { + if (args.events.length === 0) { + return args.events + } + + const transactionHashesByChain = buildTransactionHashesByChain(args.events) + const requestedTransactions = Array.from(transactionHashesByChain.values()).reduce( + (total, transactionHashes) => total + transactionHashes.length, + 0 + ) + + if (requestedTransactions === 0) { + return args.events + } + + const allowedFamilyKeys = new Set(args.events.map((event) => toVaultKey(event.chainId, event.familyVaultAddress))) + const transactionEvents = await fetchActivityEventsByTransactionHashes( + transactionHashesByChain, + args.version, + args.maxTimestamp + ) + const enrichedEvents = mergeAddressScopedRawPnlEventsWithTransactionActivity( + args.events, + transactionEvents, + allowedFamilyKeys + ) + + debugLog('protocol-return-history', 'enriched protocol return raw events with same-family tx context', { + addressEvents: args.events.length, + enrichedEvents: enrichedEvents.length, + requestedTransactions, + txDeposits: transactionEvents.deposits.length, + txWithdrawals: transactionEvents.withdrawals.length, + txTransfers: transactionEvents.transfers.length + }) + + return enrichedEvents +} + +export function buildProtocolReturnLedgers(args: { + events: TRawPnlEvent[] + userAddress: string + metadata: Map + ppsData: Map> + priceData: Map> + ethPriceData?: Map + currentTimestamp?: number +}): Map { + const userAddress = lowerCaseAddress(args.userAddress) + const effectiveEvents = buildEffectiveSimpleEvents(args.events, userAddress) + const ledgers = groupEventsByTransaction(effectiveEvents).reduce((nextLedgers, txEvents) => { + groupTransactionEventsByFamily(txEvents).forEach((txFamilyEvents) => { + normalizeStakingWrapperEvents(txFamilyEvents, userAddress).forEach((event) => { + processEvent(nextLedgers, event, { + userAddress, + metadata: args.metadata, + ppsData: args.ppsData, + priceData: args.priceData, + ethPriceData: args.ethPriceData ?? new Map() + }) + }) + }) + return nextLedgers + }, new Map()) + + const finalTimestamp = + args.currentTimestamp ?? + sortEvents(effectiveEvents).reduce((maxTimestamp, event) => Math.max(maxTimestamp, event.blockTimestamp), 0) + + return Array.from(ledgers.entries()).reduce>((finalLedgers, [key, ledger]) => { + finalLedgers.set(key, accrueLedgerExposure(ledger, finalTimestamp)) + return finalLedgers + }, new Map()) +} + +function ledgerIssues(args: { + ledger: TProtocolReturnLedger + metadata: VaultMetadata | undefined + currentPps: number | null +}): TProtocolReturnIssue[] { + return [ + ...(args.metadata ? [] : (['missing_metadata'] as const)), + ...(args.ledger.missingPps || args.currentPps === null ? (['missing_pps'] as const) : []), + ...(args.ledger.missingReceiptPrice ? (['missing_receipt_price'] as const) : []), + ...(args.ledger.unmatchedExitShares > ZERO ? (['unmatched_exit'] as const) : []) + ] +} + +function vaultStatus(issues: TProtocolReturnIssue[]): THoldingsPnLSimpleStatus { + if (issues.includes('missing_metadata')) { + return 'missing_metadata' + } + + if (issues.includes('missing_pps')) { + return 'missing_pps' + } + + if (issues.includes('missing_receipt_price')) { + return 'missing_receipt_price' + } + + return issues.includes('unmatched_exit') ? 'partial' : 'ok' +} + +function computeVaultGrowthWeightEth(args: { + ledger: TProtocolReturnLedger + metadata: VaultMetadata | undefined + ppsData: Map> + currentTimestamp: number +}): number | null { + if (args.ledger.missingReceiptEthPrice) { + return null + } + + const vaultKey = toVaultKey(args.ledger.chainId, args.ledger.vaultAddress) + const shareDecimals = args.metadata?.decimals ?? 18 + const currentPps = getEventPps(args.ppsData.get(vaultKey), args.currentTimestamp) + const currentShares = args.ledger.lots.reduce((total, lot) => total + lot.shares, ZERO) + const sharesFormatted = formatAmount(currentShares, shareDecimals) + const currentUnderlying = currentPps === null ? 0 : sharesFormatted * currentPps + const unrealizedBaselineWeightEth = getOutstandingBaselineWeightEth(args.ledger.lots) + const currentWeightEth = args.ledger.lots.reduce( + (total, lot) => total + scaleNumber(currentUnderlying, lot.shares, currentShares) * lot.receiptPriceEth, + 0 + ) + const unrealizedGrowthWeightEth = currentWeightEth - unrealizedBaselineWeightEth + + return args.ledger.realizedGrowthWeightEth + unrealizedGrowthWeightEth +} + +function buildGrowthWeightEthSummary(args: { + ledgers: Map + metadata: Map + ppsData: Map> + currentTimestamp: number +}): number | null { + const perVaultGrowthWeightEth = Array.from(args.ledgers.values()).flatMap((ledger) => { + const growthWeightEth = computeVaultGrowthWeightEth({ + ledger, + metadata: args.metadata.get(toVaultKey(ledger.chainId, ledger.vaultAddress)), + ppsData: args.ppsData, + currentTimestamp: args.currentTimestamp + }) + + return growthWeightEth === null ? [] : [growthWeightEth] + }) + + return perVaultGrowthWeightEth.length > 0 ? perVaultGrowthWeightEth.reduce((total, value) => total + value, 0) : null +} + +function buildOpenBaselineCompositionUsd(args: { + ledgers: Map + metadata: Map +}): { + stable: number + ethFamily: number + other: number +} { + return Array.from(args.ledgers.values()).reduce( + (composition, ledger) => { + const metadata = args.metadata.get(toVaultKey(ledger.chainId, ledger.vaultAddress)) + const bucket = classifyOpenBaselineBucket(metadata) + const openBaselineWeightUsd = getOutstandingBaselineWeightUsd(ledger.lots) + + if (bucket === 'stable') { + composition.stable += openBaselineWeightUsd + return composition + } + + if (bucket === 'ethFamily') { + composition.ethFamily += openBaselineWeightUsd + return composition + } + + composition.other += openBaselineWeightUsd + return composition + }, + { + stable: 0, + ethFamily: 0, + other: 0 + } + ) +} + +export function materializeProtocolReturnVaults(args: { + ledgers: Map + metadata: Map + ppsData: Map> + currentTimestamp: number +}): HoldingsPnLSimpleVault[] { + return Array.from(args.ledgers.values()).map((ledger) => { + const vaultKey = toVaultKey(ledger.chainId, ledger.vaultAddress) + const metadata = args.metadata.get(vaultKey) + const shareDecimals = metadata?.decimals ?? 18 + const ppsMap = args.ppsData.get(vaultKey) + const currentPps = ppsMap ? getPPS(ppsMap, args.currentTimestamp) : null + const currentShares = ledger.lots.reduce((total, lot) => total + lot.shares, ZERO) + const sharesFormatted = formatAmount(currentShares, shareDecimals) + const currentUnderlying = currentPps === null ? 0 : sharesFormatted * currentPps + const unrealizedBaselineUnderlying = ledger.lots.reduce((total, lot) => total + lot.baselineUnderlying, 0) + const unrealizedBaselineWeightUsd = ledger.lots.reduce( + (total, lot) => total + lot.baselineUnderlying * lot.receiptPriceUsd, + 0 + ) + const currentWeightUsd = ledger.lots.reduce( + (total, lot) => total + scaleNumber(currentUnderlying, lot.shares, currentShares) * lot.receiptPriceUsd, + 0 + ) + const unrealizedGrowthUnderlying = currentUnderlying - unrealizedBaselineUnderlying + const unrealizedGrowthWeightUsd = currentWeightUsd - unrealizedBaselineWeightUsd + const baselineExposureUnderlyingYears = ledger.baselineExposureUnderlyingSeconds / SECONDS_PER_YEAR + const baselineExposureWeightUsdYears = ledger.baselineExposureWeightUsdSeconds / SECONDS_PER_YEAR + const baselineWeightUsd = ledger.realizedBaselineWeightUsd + unrealizedBaselineWeightUsd + const growthWeightUsd = ledger.realizedGrowthWeightUsd + unrealizedGrowthWeightUsd + const issues = ledgerIssues({ ledger, metadata, currentPps }) + const status = vaultStatus(issues) + + return { + chainId: ledger.chainId, + vaultAddress: ledger.vaultAddress, + status, + issues, + shares: currentShares.toString(), + sharesFormatted, + pricePerShare: currentPps ?? 0, + currentUnderlying, + baselineUnderlying: ledger.realizedBaselineUnderlying + unrealizedBaselineUnderlying, + baselineExposureUnderlyingYears, + baselineExposureWeightUsdYears, + realizedBaselineUnderlying: ledger.realizedBaselineUnderlying, + unrealizedBaselineUnderlying, + realizedGrowthUnderlying: ledger.realizedGrowthUnderlying, + unrealizedGrowthUnderlying, + growthUnderlying: ledger.realizedGrowthUnderlying + unrealizedGrowthUnderlying, + baselineWeightUsd, + growthWeightUsd, + realizedGrowthWeightUsd: ledger.realizedGrowthWeightUsd, + unrealizedGrowthWeightUsd, + protocolReturnPct: protocolReturnPct(growthWeightUsd, baselineWeightUsd), + annualizedProtocolReturnPct: annualizedProtocolReturnPct(growthWeightUsd, baselineExposureWeightUsdYears), + receiptCount: ledger.receiptCount, + exitCount: ledger.exitCount, + deposits: ledger.deposits, + withdrawals: ledger.withdrawals, + transfersIn: ledger.transfersIn, + transfersOut: ledger.transfersOut, + unmatchedExitShares: ledger.unmatchedExitShares.toString(), + unmatchedExitSharesFormatted: formatAmount(ledger.unmatchedExitShares, shareDecimals), + metadata: { + symbol: metadata?.token.symbol ?? null, + decimals: metadata?.decimals ?? 18, + assetDecimals: metadata?.token.decimals ?? 18, + tokenAddress: metadata?.token.address ?? null + } + } + }) +} + +function buildSummary(vaults: HoldingsPnLSimpleVault[]): HoldingsPnLSimpleResponse['summary'] { + const baselineWeightUsd = vaults.reduce((total, vault) => total + vault.baselineWeightUsd, 0) + const growthWeightUsd = vaults.reduce((total, vault) => total + vault.growthWeightUsd, 0) + const baselineExposureWeightUsdYears = vaults.reduce( + (total, vault) => total + vault.baselineExposureWeightUsdYears, + 0 + ) + const realizedGrowthWeightUsd = vaults.reduce((total, vault) => total + vault.realizedGrowthWeightUsd, 0) + const unrealizedGrowthWeightUsd = vaults.reduce((total, vault) => total + vault.unrealizedGrowthWeightUsd, 0) + const completeVaults = vaults.filter((vault) => vault.status === 'ok').length + const partialVaults = vaults.length - completeVaults + + return { + totalVaults: vaults.length, + completeVaults, + partialVaults, + baselineWeightUsd, + growthWeightUsd, + baselineExposureWeightUsdYears, + realizedGrowthWeightUsd, + unrealizedGrowthWeightUsd, + protocolReturnPct: protocolReturnPct(growthWeightUsd, baselineWeightUsd), + annualizedProtocolReturnPct: annualizedProtocolReturnPct(growthWeightUsd, baselineExposureWeightUsdYears), + isComplete: partialVaults === 0 + } +} + +function selectHistoryFamilies(vaults: HoldingsPnLSimpleVault[], limit = 5): HoldingsPnLSimpleVault[] { + return vaults + .filter((vault) => vault.baselineWeightUsd > 0 && vault.protocolReturnPct !== null) + .sort((left, right) => right.baselineWeightUsd - left.baselineWeightUsd) + .slice(0, limit) +} + +export function buildProtocolReturnHistorySeries(args: { + events: TRawPnlEvent[] + userAddress: string + metadata: Map + ppsData: Map> + priceData: Map> + ethPriceData?: Map + timestamps: number[] + selectedVaultKey?: string + selectedVaultKeys?: string[] +}): HoldingsPnLSimpleHistoryPoint[] { + const userAddress = lowerCaseAddress(args.userAddress) + const effectiveEvents = buildEffectiveSimpleEvents(args.events, userAddress) + const groupedTransactions = groupEventsByTransaction(effectiveEvents) + let transactionIndex = 0 + let ledgers = new Map() + let previousTimestamp: number | null = null + let previousGrowthWeightUsd = 0 + let previousExposureWeightUsdYears = 0 + let growthIndex: number | null = null + + return args.timestamps.map((timestamp) => { + while ( + transactionIndex < groupedTransactions.length && + groupedTransactions[transactionIndex]![0]!.blockTimestamp <= timestamp + ) { + groupTransactionEventsByFamily(groupedTransactions[transactionIndex]!).forEach((txFamilyEvents) => { + normalizeStakingWrapperEvents(txFamilyEvents, userAddress).forEach((event) => { + processEvent(ledgers, event, { + userAddress, + metadata: args.metadata, + ppsData: args.ppsData, + priceData: args.priceData, + ethPriceData: args.ethPriceData ?? new Map() + }) + }) + }) + transactionIndex += 1 + } + + ledgers = Array.from(ledgers.entries()).reduce>((nextLedgers, [key, ledger]) => { + nextLedgers.set(key, accrueLedgerExposure(ledger, timestamp)) + return nextLedgers + }, new Map()) + + const vaults = materializeProtocolReturnVaults({ + ledgers, + metadata: args.metadata, + ppsData: args.ppsData, + currentTimestamp: timestamp + }) + const selectedVaultKeys = args.selectedVaultKeys ?? (args.selectedVaultKey ? [args.selectedVaultKey] : []) + const selectedVaultKeySet = new Set(selectedVaultKeys) + const selectedVaults = selectedVaultKeys.length + ? vaults.filter((vault) => selectedVaultKeySet.has(toVaultKey(vault.chainId, vault.vaultAddress))) + : [] + const summary = buildSummary(vaults) + const growthWeightEth = buildGrowthWeightEthSummary({ + ledgers, + metadata: args.metadata, + ppsData: args.ppsData, + currentTimestamp: timestamp + }) + growthIndex = advanceGrowthIndex({ + previousIndex: growthIndex, + deltaGrowthWeightUsd: summary.growthWeightUsd - previousGrowthWeightUsd, + deltaExposureWeightUsdYears: summary.baselineExposureWeightUsdYears - previousExposureWeightUsdYears, + deltaSeconds: previousTimestamp === null ? 0 : Math.max(0, timestamp - previousTimestamp), + hasCapital: summary.baselineWeightUsd > 0 || summary.growthWeightUsd !== 0 + }) + previousTimestamp = timestamp + previousGrowthWeightUsd = summary.growthWeightUsd + previousExposureWeightUsdYears = summary.baselineExposureWeightUsdYears + + return { + date: timestampToDateString(timestamp), + timestamp, + growthWeightUsd: summary.growthWeightUsd, + growthWeightEth, + protocolReturnPct: summary.protocolReturnPct, + annualizedProtocolReturnPct: summary.annualizedProtocolReturnPct, + growthIndex, + ...(selectedVaultKeys.length > 0 + ? { + currentUnderlying: selectedVaults.reduce((sum, vault) => sum + vault.currentUnderlying, 0), + growthUnderlying: selectedVaults.reduce((sum, vault) => sum + vault.growthUnderlying, 0), + sharesFormatted: selectedVaults.reduce((sum, vault) => sum + vault.sharesFormatted, 0), + pricePerShare: selectedVaults.length === 1 ? selectedVaults[0]!.pricePerShare : 0 + } + : {}) + } + }) +} + +export function buildProtocolReturnFamilyHistorySeries(args: { + events: TRawPnlEvent[] + userAddress: string + metadata: Map + ppsData: Map> + priceData: Map> + ethPriceData?: Map + timestamps: number[] + selectedVaults: HoldingsPnLSimpleVault[] +}): HoldingsPnLSimpleHistoryFamilySeries[] { + if (args.selectedVaults.length === 0) { + return [] + } + + const userAddress = lowerCaseAddress(args.userAddress) + const effectiveEvents = buildEffectiveSimpleEvents(args.events, userAddress) + const groupedTransactions = groupEventsByTransaction(effectiveEvents) + const selectedVaultKeys = new Set(args.selectedVaults.map((vault) => toVaultKey(vault.chainId, vault.vaultAddress))) + const selectedVaultsByKey = new Map( + args.selectedVaults.map((vault) => [toVaultKey(vault.chainId, vault.vaultAddress), vault] as const) + ) + const familyPointMap = new Map( + Array.from(selectedVaultKeys, (key) => [key, []] as const) + ) + + let transactionIndex = 0 + let ledgers = new Map() + const familyIndexState = new Map< + string, + { + previousTimestamp: number | null + previousGrowthWeightUsd: number + previousExposureWeightUsdYears: number + growthIndex: number | null + } + >( + Array.from( + selectedVaultKeys, + (key) => + [ + key, + { + previousTimestamp: null, + previousGrowthWeightUsd: 0, + previousExposureWeightUsdYears: 0, + growthIndex: null + } + ] as const + ) + ) + + args.timestamps.forEach((timestamp) => { + while ( + transactionIndex < groupedTransactions.length && + groupedTransactions[transactionIndex]![0]!.blockTimestamp <= timestamp + ) { + groupTransactionEventsByFamily(groupedTransactions[transactionIndex]!).forEach((txFamilyEvents) => { + normalizeStakingWrapperEvents(txFamilyEvents, userAddress).forEach((event) => { + processEvent(ledgers, event, { + userAddress, + metadata: args.metadata, + ppsData: args.ppsData, + priceData: args.priceData, + ethPriceData: args.ethPriceData ?? new Map() + }) + }) + }) + transactionIndex += 1 + } + + ledgers = Array.from(ledgers.entries()).reduce>((nextLedgers, [key, ledger]) => { + nextLedgers.set(key, accrueLedgerExposure(ledger, timestamp)) + return nextLedgers + }, new Map()) + + const vaultsByKey = new Map( + materializeProtocolReturnVaults({ + ledgers, + metadata: args.metadata, + ppsData: args.ppsData, + currentTimestamp: timestamp + }).map((vault) => [toVaultKey(vault.chainId, vault.vaultAddress), vault] as const) + ) + + selectedVaultKeys.forEach((vaultKey) => { + const familyVault = vaultsByKey.get(vaultKey) + const state = familyIndexState.get(vaultKey) + + if (!state) { + return + } + + const hasOpenPosition = (familyVault?.sharesFormatted ?? 0) > 0 + + state.growthIndex = advanceGrowthIndex({ + previousIndex: state.growthIndex, + deltaGrowthWeightUsd: (familyVault?.growthWeightUsd ?? 0) - state.previousGrowthWeightUsd, + deltaExposureWeightUsdYears: + (familyVault?.baselineExposureWeightUsdYears ?? 0) - state.previousExposureWeightUsdYears, + deltaSeconds: state.previousTimestamp === null ? 0 : Math.max(0, timestamp - state.previousTimestamp), + hasCapital: (familyVault?.baselineWeightUsd ?? 0) > 0 || (familyVault?.growthWeightUsd ?? 0) !== 0 + }) + state.previousTimestamp = timestamp + state.previousGrowthWeightUsd = familyVault?.growthWeightUsd ?? 0 + state.previousExposureWeightUsdYears = familyVault?.baselineExposureWeightUsdYears ?? 0 + + familyPointMap.get(vaultKey)?.push({ + date: timestampToDateString(timestamp), + timestamp, + protocolReturnPct: hasOpenPosition ? (familyVault?.protocolReturnPct ?? null) : null, + growthWeightUsd: hasOpenPosition ? (familyVault?.growthWeightUsd ?? null) : null, + growthIndex: hasOpenPosition ? state.growthIndex : null + }) + }) + }) + + return Array.from(selectedVaultKeys).flatMap((vaultKey) => { + const selectedVault = selectedVaultsByKey.get(vaultKey) + const points = familyPointMap.get(vaultKey) + + if (!selectedVault || !points) { + return [] + } + + return [ + { + chainId: selectedVault.chainId, + vaultAddress: selectedVault.vaultAddress, + symbol: selectedVault.metadata.symbol, + status: selectedVault.status, + dataPoints: points + } + ] + }) +} + +export async function getHoldingsProtocolReturnHistory( + userAddress: string, + version: VaultVersion = 'all', + fetchType: HoldingsEventFetchType = 'seq', + paginationMode: HoldingsEventPaginationMode = 'paged', + timeframe: '1y' | 'all' = '1y', + requestedVaultFilters?: Array<{ chainId: number; vaultAddress: string }> | string, + legacyVaultChainId?: number +): Promise { + debugLog('protocol-return-history', 'starting holdings protocol return history calculation', { + version, + fetchType, + paginationMode, + timeframe + }) + reportHoldingsProgress(12, 'Fetching historical user data', 'Starting protocol return history') + + const requestedVaults = normalizeProtocolReturnVaultFilters(requestedVaultFilters, legacyVaultChainId) + const singleRequestedVault = requestedVaults?.length === 1 ? requestedVaults[0] : undefined + const settledContext = await getSettledVersionedPpsContext({ + userAddress, + version, + fetchType, + paginationMode, + requestedVault: singleRequestedVault, + vaultIdentifiers: requestedVaults + }) + const selectedEvents = filterEventsByRequestedVaults(settledContext.selectedEvents, requestedVaults) + reportHoldingsProgress( + 30, + 'Loaded wallet events and vault share prices', + `${settledContext.selectedEvents.length} events` + ) + const rawEvents = await enrichSimpleHistoryRawEvents({ + events: selectedEvents, + version, + maxTimestamp: settledContext.maxTimestamp + }) + reportHoldingsProgress(40, 'Enriched historical wallet events', `${rawEvents.length} events`) + const effectiveEvents = rawEvents + const filteredVaultIdentifiers = filterVaultIdentifiersByRequestedVaults( + getVaultIdentifiers(effectiveEvents), + requestedVaults + ) + const vaultMetadata = settledContext.vaultMetadata + + if (effectiveEvents.length === 0 || filteredVaultIdentifiers.length === 0) { + reportHoldingsProgress(94, 'No historical protocol return events found', null) + return { + address: lowerCaseAddress(userAddress), + version, + timeframe, + generatedAt: new Date().toISOString(), + summary: { + totalVaults: 0, + completeVaults: 0, + partialVaults: 0, + recommendedGrowthDisplay: 'index', + recommendedGrowthDisplayReason: 'mixed', + openBaselineCompositionUsd: { + stable: 0, + ethFamily: 0, + other: 0 + }, + isComplete: true + }, + dataPoints: [], + familySeries: [] + } + } + + const timestamps = getProtocolReturnTimestamps(effectiveEvents, timeframe) + const latestTimestamp = timestamps[timestamps.length - 1] ?? settledContext.maxTimestamp + const baseReceiptPriceRequests = buildReceiptPriceRequests({ + events: effectiveEvents, + metadata: vaultMetadata, + userAddress, + currentTimestamp: latestTimestamp + }) + const receiptPriceRequests = expandNestedVaultAssetPriceRequests(baseReceiptPriceRequests, vaultMetadata) + const ethReceiptPriceTimestamps = Array.from( + new Set(receiptPriceRequests.flatMap((request) => request.timestamps)) + ).sort((left, right) => left - right) + + const ppsIdentifiers = mergeVaultIdentifiers([ + ...filteredVaultIdentifiers, + ...getNestedVaultPpsIdentifiersFromPriceRequests(baseReceiptPriceRequests, vaultMetadata) + ]) + reportHoldingsProgress( + 52, + 'Prepared historical price requests', + `${receiptPriceRequests.length} receipt price series, ${ethReceiptPriceTimestamps.length} ETH price points, ${ppsIdentifiers.length} PPS series` + ) + const [fetchedPriceData, ethPriceData] = await Promise.all([ + fetchReceiptPrices(receiptPriceRequests), + fetchEthReceiptPrices(ethReceiptPriceTimestamps) + ]) + reportHoldingsProgress(72, 'Fetched historical receipt prices', `${receiptPriceRequests.length} price series`) + const priceData = deriveNestedVaultAssetPriceData({ + priceData: fetchedPriceData, + priceRequests: receiptPriceRequests, + vaultMetadata, + ppsData: settledContext.ppsData + }) + + const finalLedgers = buildProtocolReturnLedgers({ + events: effectiveEvents, + userAddress, + metadata: vaultMetadata, + ppsData: settledContext.ppsData, + priceData, + ethPriceData, + currentTimestamp: latestTimestamp + }) + const finalVaults = materializeProtocolReturnVaults({ + ledgers: finalLedgers, + metadata: vaultMetadata, + ppsData: settledContext.ppsData, + currentTimestamp: latestTimestamp + }) + reportHoldingsProgress(82, 'Calculated protocol return ledgers', `${finalVaults.length} vaults`) + const selectedHistoryFamilies = selectHistoryFamilies(finalVaults) + const history = buildProtocolReturnHistorySeries({ + events: effectiveEvents, + userAddress, + metadata: vaultMetadata, + ppsData: settledContext.ppsData, + priceData, + ethPriceData, + timestamps, + selectedVaultKeys: requestedVaults + ? filteredVaultIdentifiers.map((vault) => toVaultKey(vault.chainId, vault.vaultAddress)) + : undefined + }) + const familySeries = buildProtocolReturnFamilyHistorySeries({ + events: effectiveEvents, + userAddress, + metadata: vaultMetadata, + ppsData: settledContext.ppsData, + priceData, + ethPriceData, + timestamps, + selectedVaults: requestedVaults ? [] : selectedHistoryFamilies + }) + reportHoldingsProgress(92, 'Built historical chart series', `${history.length} chart points`) + const openBaselineCompositionUsd = buildOpenBaselineCompositionUsd({ + ledgers: finalLedgers, + metadata: vaultMetadata + }) + const { recommendedGrowthDisplay, recommendedGrowthDisplayReason } = + resolveRecommendedGrowthDisplay(openBaselineCompositionUsd) + + debugLog('protocol-return-history', 'completed holdings protocol return history calculation', { + version, + timeframe, + points: history.length, + totalVaults: finalVaults.length, + addressDeposits: settledContext.events.deposits.length, + addressWithdrawals: settledContext.events.withdrawals.length, + addressTransfersIn: settledContext.events.transfersIn.length, + addressTransfersOut: settledContext.events.transfersOut.length, + ppsResolved: settledContext.ppsData.size, + ppsRequested: settledContext.ppsIdentifiers.length, + recommendedGrowthDisplay, + recommendedGrowthDisplayReason + }) + + return { + address: lowerCaseAddress(userAddress), + version, + timeframe, + generatedAt: new Date().toISOString(), + summary: { + totalVaults: finalVaults.length, + completeVaults: finalVaults.filter((vault) => vault.status === 'ok').length, + partialVaults: finalVaults.filter((vault) => vault.status !== 'ok').length, + recommendedGrowthDisplay, + recommendedGrowthDisplayReason, + openBaselineCompositionUsd, + isComplete: finalVaults.every((vault) => vault.status === 'ok') + }, + dataPoints: history, + familySeries + } +} diff --git a/api/lib/holdings/services/pnlSimpleHistory.test.ts b/api/lib/holdings/services/pnlSimpleHistory.test.ts new file mode 100644 index 000000000..7816ff8e3 --- /dev/null +++ b/api/lib/holdings/services/pnlSimpleHistory.test.ts @@ -0,0 +1,154 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { VaultMetadata } from '../types' +import { toVaultKey } from './pnlShared' +import type { TRawPnlEvent } from './pnlTypes' + +const fetchHistoricalPricesForTokenTimestampsMock = vi.fn() +const getPriceAtTimestampMock = vi.fn() +const getSettledVersionedPpsContextMock = vi.fn() +const getVaultIdentifiersMock = vi.fn() +const fetchActivityEventsByTransactionHashesMock = vi.fn() +const generateDailyTimestampsMock = vi.fn() +const generateDailyTimestampsFromRangeMock = vi.fn() +const toSettledDayTimestampMock = vi.fn() +const timestampToDateStringMock = vi.fn() +const getPPSMock = vi.fn() + +vi.mock('./defillama', () => ({ + fetchHistoricalPricesForTokenTimestamps: fetchHistoricalPricesForTokenTimestampsMock, + getChainPrefix: vi.fn(() => 'ethereum'), + getPriceAtTimestamp: getPriceAtTimestampMock +})) + +vi.mock('./settledHoldingsContext', () => ({ + getSettledVersionedPpsContext: getSettledVersionedPpsContextMock, + getVaultIdentifiers: getVaultIdentifiersMock +})) + +vi.mock('./graphql', () => ({ + fetchActivityEventsByTransactionHashes: fetchActivityEventsByTransactionHashesMock +})) + +vi.mock('./holdings', () => ({ + generateDailyTimestamps: generateDailyTimestampsMock, + generateDailyTimestampsFromRange: generateDailyTimestampsFromRangeMock, + toSettledDayTimestamp: toSettledDayTimestampMock, + timestampToDateString: timestampToDateStringMock +})) + +vi.mock('./kong', () => ({ + getPPS: getPPSMock +})) + +vi.mock('./nestedVaultPrices', () => ({ + expandNestedVaultAssetPriceRequests: vi.fn((requests: unknown[]) => requests), + deriveNestedVaultAssetPriceData: vi.fn(({ priceData }: { priceData: Map> }) => priceData), + getNestedVaultPpsIdentifiersFromPriceRequests: vi.fn(() => []), + mergeVaultIdentifiers: vi.fn((identifiers: unknown[]) => identifiers) +})) + +vi.mock('./pnlEvents', () => ({ + mergeAddressScopedRawPnlEventsWithTransactionActivity: vi.fn((events: TRawPnlEvent[]) => events) +})) + +const USER = '0x1111111111111111111111111111111111111111' +const VAULT = '0x3333333333333333333333333333333333333333' +const ASSET = '0x4444444444444444444444444444444444444444' +const ONE = 10n ** 18n +const HISTORY_START_TIMESTAMP = 1_704_067_200 +const VAULT_KEY = toVaultKey(1, VAULT) +const ASSET_PRICE_KEY = `ethereum:${ASSET}` + +const metadata = new Map([ + [ + VAULT_KEY, + { + address: VAULT, + chainId: 1, + version: 'v3', + category: 'stable', + token: { + address: ASSET, + symbol: 'TST', + decimals: 18 + }, + decimals: 18 + } + ] +]) + +const event = { + kind: 'deposit', + id: 'deposit', + chainId: 1, + vaultAddress: VAULT, + familyVaultAddress: VAULT, + isStakingVault: false, + blockNumber: 1, + blockTimestamp: 1_600_000_000, + logIndex: 0, + transactionHash: '0xdeposit', + transactionFrom: USER, + owner: USER, + sender: USER, + shares: 100n * ONE, + assets: 100n * ONE, + scopes: { + address: true, + tx: false + } +} as TRawPnlEvent + +describe('getHoldingsProtocolReturnHistory', () => { + beforeEach(() => { + vi.resetModules() + vi.clearAllMocks() + generateDailyTimestampsMock.mockReturnValue([200]) + generateDailyTimestampsFromRangeMock.mockReturnValue([HISTORY_START_TIMESTAMP, HISTORY_START_TIMESTAMP + 86_400]) + toSettledDayTimestampMock.mockImplementation((timestamp: number) => timestamp + 1) + timestampToDateStringMock.mockImplementation((timestamp: number) => `date-${timestamp}`) + getPPSMock.mockReturnValue(1) + getPriceAtTimestampMock.mockReturnValue(1) + fetchHistoricalPricesForTokenTimestampsMock.mockResolvedValue( + new Map([[ASSET_PRICE_KEY, new Map([[HISTORY_START_TIMESTAMP + 1, 1]])]]) + ) + fetchActivityEventsByTransactionHashesMock.mockResolvedValue({ + deposits: [], + withdrawals: [], + transfers: [] + }) + getVaultIdentifiersMock.mockReturnValue([{ chainId: 1, vaultAddress: VAULT }]) + getSettledVersionedPpsContextMock.mockResolvedValue({ + address: USER, + latestSettledDayTimestamp: 200, + maxTimestamp: 201, + events: { + deposits: [], + withdrawals: [], + transfersIn: [], + transfersOut: [] + }, + timeline: [], + hasActivity: true, + rawEvents: [event], + rawVaultIdentifiers: [{ chainId: 1, vaultAddress: VAULT }], + vaultMetadata: metadata, + selectedEvents: [event], + selectedVaultIdentifiers: [{ chainId: 1, vaultAddress: VAULT }], + ppsIdentifiers: [{ chainId: 1, vaultAddress: VAULT }], + ppsData: new Map([[VAULT_KEY, new Map([[HISTORY_START_TIMESTAMP + 1, 1]])]]) + }) + }) + + it('starts all timeframe at the supported history floor', async () => { + const { getHoldingsProtocolReturnHistory } = await import('./pnlSimple') + + const response = await getHoldingsProtocolReturnHistory(USER, 'all', 'seq', 'paged', 'all') + + expect(generateDailyTimestampsFromRangeMock).toHaveBeenCalledWith(HISTORY_START_TIMESTAMP, 200) + expect(response.dataPoints.map((point) => point.timestamp)).toEqual([ + HISTORY_START_TIMESTAMP + 1, + HISTORY_START_TIMESTAMP + 86_401 + ]) + }) +}) diff --git a/api/lib/holdings/services/pnlTypes.ts b/api/lib/holdings/services/pnlTypes.ts new file mode 100644 index 000000000..1f13eb5d1 --- /dev/null +++ b/api/lib/holdings/services/pnlTypes.ts @@ -0,0 +1,64 @@ +export type TLot = { + shares: bigint + costBasis: bigint | null + acquiredAt?: number +} + +export type TRawScopes = { + address: boolean + tx: boolean +} + +export type TRawPnlEvent = + | { + kind: 'deposit' + id: string + chainId: number + vaultAddress: string + familyVaultAddress: string + isStakingVault: boolean + blockNumber: number + blockTimestamp: number + logIndex: number + transactionHash: string + transactionFrom: string + owner: string + sender: string + shares: bigint + assets: bigint + scopes: TRawScopes + } + | { + kind: 'withdrawal' + id: string + chainId: number + vaultAddress: string + familyVaultAddress: string + isStakingVault: boolean + blockNumber: number + blockTimestamp: number + logIndex: number + transactionHash: string + transactionFrom: string + owner: string + shares: bigint + assets: bigint + scopes: TRawScopes + } + | { + kind: 'transfer' + id: string + chainId: number + vaultAddress: string + familyVaultAddress: string + isStakingVault: boolean + blockNumber: number + blockTimestamp: number + logIndex: number + transactionHash: string + transactionFrom: string + sender: string + receiver: string + shares: bigint + scopes: TRawScopes + } diff --git a/api/lib/holdings/services/progress.test.ts b/api/lib/holdings/services/progress.test.ts new file mode 100644 index 000000000..966fc1a27 --- /dev/null +++ b/api/lib/holdings/services/progress.test.ts @@ -0,0 +1,52 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const getHoldingsRedisClientMock = vi.fn() +const isHoldingsStorageEnabledMock = vi.fn() +const handleHoldingsRedisErrorMock = vi.fn() + +vi.mock('../storage/redis', () => ({ + getHoldingsRedisClient: getHoldingsRedisClientMock, + isHoldingsStorageEnabled: isHoldingsStorageEnabledMock, + handleHoldingsRedisError: handleHoldingsRedisErrorMock +})) + +describe('Redis holdings progress', () => { + beforeEach(() => { + vi.resetModules() + vi.clearAllMocks() + isHoldingsStorageEnabledMock.mockReturnValue(true) + }) + + it('persists and reads progress records with a ttl', async () => { + const redisState = { value: null as string | null } + const getMock = vi.fn().mockImplementation(() => Promise.resolve(redisState.value)) + const setMock = vi.fn().mockImplementation((_key: string, value: string) => { + redisState.value = value + return Promise.resolve('OK') + }) + getHoldingsRedisClientMock.mockReturnValue({ + get: getMock, + set: setMock + }) + + const { getHoldingsProgress, startHoldingsProgress, updateHoldingsProgress } = await import('./progress') + const id = await startHoldingsProgress({ + id: 'portfolio:test', + route: 'history', + address: '0x0000000000000000000000000000000000000001', + message: 'Fetching historical user data' + }) + await updateHoldingsProgress(id, { + progress: 40, + message: 'Fetched prices' + }) + + const record = await getHoldingsProgress(id) + + expect(id).toBe('portfolio:test') + expect(record?.progress).toBe(40) + expect(record?.message).toBe('Fetched prices') + expect(setMock.mock.calls[0]?.[0]).toBe('holdings:progress:portfolio:test') + expect(setMock.mock.calls[0]?.[2]).toEqual({ ex: 10 * 60 }) + }) +}) diff --git a/api/lib/holdings/services/progress.ts b/api/lib/holdings/services/progress.ts new file mode 100644 index 000000000..fafa07a8a --- /dev/null +++ b/api/lib/holdings/services/progress.ts @@ -0,0 +1,255 @@ +import { createHash } from 'node:crypto' +import { getHoldingsRedisClient, handleHoldingsRedisError, isHoldingsStorageEnabled } from '../storage/redis' + +export type HoldingsProgressStatus = 'running' | 'complete' | 'error' + +export type HoldingsProgressLog = { + elapsedMs: number + scope: string + message: string + payload?: Record +} + +export type HoldingsProgressRecord = { + id: string + route: string + addressHash: string + status: HoldingsProgressStatus + progress: number + message: string + detail: string | null + startedAt: number + updatedAt: number + logs: HoldingsProgressLog[] +} + +const PROGRESS_TTL_SECONDS = 10 * 60 +const MAX_PROGRESS_LOGS = 20 +const PROGRESS_KEY_PREFIX = 'holdings:progress' + +function isValidProgressId(id: string | null | undefined): id is string { + return Boolean(id && /^[a-zA-Z0-9:_-]{1,160}$/.test(id)) +} + +function normalizeUserAddress(userAddress: string): string { + return userAddress.toLowerCase() +} + +function getUserAddressCacheKey(userAddress: string): string { + return createHash('sha256').update(normalizeUserAddress(userAddress)).digest('hex') +} + +function getProgressKey(id: string): string { + return `${PROGRESS_KEY_PREFIX}:${id}` +} + +function clampProgress(progress: number): number { + if (!Number.isFinite(progress)) { + return 0 + } + return Math.max(0, Math.min(100, Math.round(progress))) +} + +function parseJsonValue(value: unknown): unknown { + if (typeof value !== 'string') { + return value + } + + try { + return JSON.parse(value) + } catch { + return null + } +} + +function parseLogs(value: unknown): HoldingsProgressLog[] { + if (Array.isArray(value)) { + return value + .filter((entry): entry is HoldingsProgressLog => { + const candidate = entry as Partial + return ( + typeof candidate.elapsedMs === 'number' && + typeof candidate.scope === 'string' && + typeof candidate.message === 'string' + ) + }) + .slice(-MAX_PROGRESS_LOGS) + } + + return [] +} + +function parseProgressRecord(value: unknown): HoldingsProgressRecord | null { + const parsed = parseJsonValue(value) + if (!parsed || typeof parsed !== 'object') { + return null + } + + const record = parsed as Partial + const status = record.status === 'complete' || record.status === 'error' ? record.status : 'running' + const startedAt = Number(record.startedAt) + const updatedAt = Number(record.updatedAt) + + if ( + typeof record.id !== 'string' || + typeof record.route !== 'string' || + typeof record.addressHash !== 'string' || + typeof record.message !== 'string' || + !Number.isFinite(startedAt) || + !Number.isFinite(updatedAt) + ) { + return null + } + + return { + id: record.id, + route: record.route, + addressHash: record.addressHash, + status, + progress: status === 'complete' ? 100 : clampProgress(Number(record.progress)), + message: record.message, + detail: typeof record.detail === 'string' ? record.detail : null, + startedAt, + updatedAt, + logs: parseLogs(record.logs) + } +} + +async function persistProgressRecord(record: HoldingsProgressRecord): Promise { + if (!isHoldingsStorageEnabled()) { + return false + } + + const redis = getHoldingsRedisClient() + if (!redis) { + return false + } + + try { + const existingRecord = await getPersistedProgressRecord(record.id) + const existingProgress = existingRecord?.progress ?? 0 + const nextRecord: HoldingsProgressRecord = { + ...record, + progress: record.status === 'complete' ? 100 : Math.max(existingProgress, record.progress), + logs: record.logs.slice(-MAX_PROGRESS_LOGS) + } + + if (existingRecord && existingRecord.updatedAt > record.updatedAt) { + return false + } + + await redis.set(getProgressKey(record.id), JSON.stringify(nextRecord), { ex: PROGRESS_TTL_SECONDS }) + return true + } catch (error) { + handleHoldingsRedisError('progress save failed', error) + return false + } +} + +async function getPersistedProgressRecord(id: string): Promise { + if (!isHoldingsStorageEnabled()) { + return null + } + + const redis = getHoldingsRedisClient() + if (!redis) { + return null + } + + try { + return parseProgressRecord(await redis.get(getProgressKey(id))) + } catch (error) { + handleHoldingsRedisError('progress lookup failed', error) + return null + } +} + +export async function startHoldingsProgress({ + id, + route, + address, + message +}: { + id: string | null | undefined + route: string + address: string + message: string +}): Promise { + if (!isValidProgressId(id) || !isHoldingsStorageEnabled()) { + return null + } + + const now = Date.now() + const record: HoldingsProgressRecord = { + id, + route, + addressHash: getUserAddressCacheKey(address), + status: 'running', + progress: 8, + message, + detail: null, + startedAt: now, + updatedAt: now, + logs: [] + } + return (await persistProgressRecord(record)) ? id : null +} + +export async function updateHoldingsProgress( + id: string | null | undefined, + update: { + progress?: number + message?: string + detail?: string | null + status?: HoldingsProgressStatus + } +): Promise { + if (!isValidProgressId(id)) { + return + } + + const record = await getPersistedProgressRecord(id) + if (!record) { + return + } + + const nextProgress = update.progress === undefined ? record.progress : clampProgress(update.progress) + const nextRecord: HoldingsProgressRecord = { + ...record, + status: update.status ?? record.status, + progress: update.status === 'complete' ? 100 : Math.max(record.progress, nextProgress), + message: update.message ?? record.message, + detail: update.detail === undefined ? record.detail : update.detail, + updatedAt: Math.max(Date.now(), record.updatedAt + 1) + } + await persistProgressRecord(nextRecord) +} + +export async function appendHoldingsProgressLog( + id: string | null | undefined, + log: HoldingsProgressLog +): Promise { + if (!isValidProgressId(id)) { + return + } + + const record = await getPersistedProgressRecord(id) + if (!record) { + return + } + + const nextRecord: HoldingsProgressRecord = { + ...record, + logs: [...record.logs, log].slice(-MAX_PROGRESS_LOGS), + updatedAt: Math.max(Date.now(), record.updatedAt + 1) + } + await persistProgressRecord(nextRecord) +} + +export async function getHoldingsProgress(id: string | null | undefined): Promise { + if (!isValidProgressId(id)) { + return null + } + + return getPersistedProgressRecord(id) +} diff --git a/api/lib/holdings/services/ratelimit.test.ts b/api/lib/holdings/services/ratelimit.test.ts new file mode 100644 index 000000000..d629f97e5 --- /dev/null +++ b/api/lib/holdings/services/ratelimit.test.ts @@ -0,0 +1,48 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const getHoldingsRedisClientMock = vi.fn() +const isHoldingsStorageEnabledMock = vi.fn() +const handleHoldingsRedisErrorMock = vi.fn() + +vi.mock('../storage/redis', () => ({ + getHoldingsRedisClient: getHoldingsRedisClientMock, + isHoldingsStorageEnabled: isHoldingsStorageEnabledMock, + handleHoldingsRedisError: handleHoldingsRedisErrorMock +})) + +describe('Redis rate limiting', () => { + beforeEach(() => { + vi.resetModules() + vi.clearAllMocks() + isHoldingsStorageEnabledMock.mockReturnValue(true) + }) + + it('sets a one-minute ttl on the first request in a window', async () => { + const incrMock = vi.fn().mockResolvedValue(1) + const expireMock = vi.fn().mockResolvedValue(1) + getHoldingsRedisClientMock.mockReturnValue({ + incr: incrMock, + expire: expireMock, + ttl: vi.fn() + }) + + const { checkRateLimit } = await import('./ratelimit') + const result = await checkRateLimit('127.0.0.1') + + expect(result).toEqual({ allowed: true }) + expect(expireMock).toHaveBeenCalledWith(expect.stringMatching(/^holdings:rate-limit:[a-f0-9]{64}$/), 60) + }) + + it('returns retry-after from Redis ttl after the request limit is exceeded', async () => { + getHoldingsRedisClientMock.mockReturnValue({ + incr: vi.fn().mockResolvedValue(11), + expire: vi.fn(), + ttl: vi.fn().mockResolvedValue(42) + }) + + const { checkRateLimit } = await import('./ratelimit') + const result = await checkRateLimit('127.0.0.1') + + expect(result).toEqual({ allowed: false, retryAfter: 42 }) + }) +}) diff --git a/api/lib/holdings/services/ratelimit.ts b/api/lib/holdings/services/ratelimit.ts new file mode 100644 index 000000000..2a98821cb --- /dev/null +++ b/api/lib/holdings/services/ratelimit.ts @@ -0,0 +1,47 @@ +import { createHash } from 'node:crypto' +import { getHoldingsRedisClient, handleHoldingsRedisError, isHoldingsStorageEnabled } from '../storage/redis' + +const WINDOW_SECONDS = 60 +const MAX_REQUESTS = 10 +const RATE_LIMIT_KEY_PREFIX = 'holdings:rate-limit' + +export interface RateLimitResult { + allowed: boolean + retryAfter?: number +} + +function getRateLimitKey(clientIdentifier: string): string { + const identifierHash = createHash('sha256').update(clientIdentifier).digest('hex') + return `${RATE_LIMIT_KEY_PREFIX}:${identifierHash}` +} + +export async function checkRateLimit(clientIdentifier: string): Promise { + if (!isHoldingsStorageEnabled()) { + return { allowed: true } + } + + const redis = getHoldingsRedisClient() + if (!redis) { + return { allowed: true } + } + + try { + const key = getRateLimitKey(clientIdentifier) + const requestCount = await redis.incr(key) + + if (requestCount === 1) { + await redis.expire(key, WINDOW_SECONDS) + } + + if (requestCount > MAX_REQUESTS) { + const ttl = await redis.ttl(key) + const retryAfter = ttl > 0 ? ttl : WINDOW_SECONDS + return { allowed: false, retryAfter } + } + + return { allowed: true } + } catch (error) { + handleHoldingsRedisError('rate limit check failed', error) + return { allowed: true } + } +} diff --git a/api/lib/holdings/services/settledHoldingsContext.ts b/api/lib/holdings/services/settledHoldingsContext.ts new file mode 100644 index 000000000..9313fe594 --- /dev/null +++ b/api/lib/holdings/services/settledHoldingsContext.ts @@ -0,0 +1,281 @@ +import { holdingsConfig } from '../config' +import type { UserEvents, VaultMetadata } from '../types' +import { debugLog } from './debug' +import type { THistoricalPriceRequest } from './defillama' +import { + fetchUserEvents, + type HoldingsEventFetchType, + type HoldingsEventPaginationMode, + type VaultVersion +} from './graphql' +import { buildPositionTimeline, generateDailyTimestamps, getUniqueVaults, toSettledDayTimestamp } from './holdings' +import { fetchMultipleVaultsPPS, type PPSTimeline } from './kong' +import { + getNestedVaultPpsIdentifiersFromPriceRequests, + mergeVaultIdentifiers, + resolveNestedVaultAssetMetadata +} from './nestedVaultPrices' +import { buildAddressScopedRawPnlEvents } from './pnlEvents' +import { lowerCaseAddress, toVaultKey } from './pnlShared' +import type { TRawPnlEvent } from './pnlTypes' +import { fetchMultipleVaultsMetadata } from './vaults' + +type TVaultIdentifier = { + chainId: number + vaultAddress: string +} + +type TRequestedVault = { + chainId: number + vaultAddress: string +} + +type TPositionTimeline = ReturnType + +export interface TSettledAddressScopedContext { + address: string + latestSettledDayTimestamp: number + maxTimestamp: number + events: UserEvents + timeline: TPositionTimeline + hasActivity: boolean + rawEvents: TRawPnlEvent[] + rawVaultIdentifiers: TVaultIdentifier[] + vaultMetadata: Map +} + +export interface TSettledVersionedSelection { + events: TRawPnlEvent[] + vaultIdentifiers: TVaultIdentifier[] +} + +export interface TSettledVersionedPpsContext extends TSettledAddressScopedContext { + selectedEvents: TRawPnlEvent[] + selectedVaultIdentifiers: TVaultIdentifier[] + ppsIdentifiers: TVaultIdentifier[] + ppsData: Map +} + +const inFlightSettledAddressScopedContexts = new Map>() +const inFlightSettledVersionedPpsContexts = new Map>() +const CURRENT_DAY_LOOKAHEAD_SECONDS = 24 * 60 * 60 + +function getContextKey(args: { + userAddress: string + version?: VaultVersion + fetchType: HoldingsEventFetchType + paginationMode: HoldingsEventPaginationMode + requestedVault?: TRequestedVault + vaultIdentifiers?: TVaultIdentifier[] +}): string { + const normalizedVaultScope = args.vaultIdentifiers?.length + ? args.vaultIdentifiers + .map((vault) => `${vault.chainId}:${lowerCaseAddress(vault.vaultAddress)}`) + .sort() + .join(',') + : args.requestedVault + ? `${args.requestedVault.chainId}:${lowerCaseAddress(args.requestedVault.vaultAddress)}` + : 'all' + + return [ + lowerCaseAddress(args.userAddress), + args.version ?? 'all', + args.fetchType, + args.paginationMode, + normalizedVaultScope + ].join(':') +} + +export function getVaultIdentifiers(events: TRawPnlEvent[]): TVaultIdentifier[] { + return Array.from( + events + .reduce>((identifiers, event) => { + const key = toVaultKey(event.chainId, event.familyVaultAddress) + + if (!identifiers.has(key)) { + identifiers.set(key, { + chainId: event.chainId, + vaultAddress: event.familyVaultAddress + }) + } + + return identifiers + }, new Map()) + .values() + ) +} + +export function filterEventsByRequestedVault(events: TRawPnlEvent[], requestedVault?: TRequestedVault): TRawPnlEvent[] { + if (!requestedVault) { + return events + } + + const requestedVaultAddress = lowerCaseAddress(requestedVault.vaultAddress) + return events.filter( + (event) => event.chainId === requestedVault.chainId && event.familyVaultAddress === requestedVaultAddress + ) +} + +export function filterEventsByAuthoritativeVersion( + events: TRawPnlEvent[], + metadata: Map, + version: VaultVersion +): TRawPnlEvent[] { + return events.filter((event) => { + const eventMetadata = metadata.get(toVaultKey(event.chainId, event.familyVaultAddress)) + + if (eventMetadata?.isHidden) { + return false + } + + if (version === 'all') { + return true + } + + return eventMetadata?.version === version + }) +} + +function buildUnderlyingTokenRequests( + vaultIdentifiers: TVaultIdentifier[], + vaultMetadata: Map +): THistoricalPriceRequest[] { + return Array.from( + vaultIdentifiers + .reduce>((requests, vault) => { + const metadata = vaultMetadata.get(toVaultKey(vault.chainId, vault.vaultAddress)) + + if (!metadata) { + return requests + } + + const requestKey = `${metadata.chainId}:${metadata.token.address.toLowerCase()}` + if (!requests.has(requestKey)) { + requests.set(requestKey, { + chainId: metadata.chainId, + address: metadata.token.address, + timestamps: [] + }) + } + + return requests + }, new Map()) + .values() + ) +} + +export function selectVersionedEvents( + context: TSettledAddressScopedContext, + version: VaultVersion, + requestedVault?: TRequestedVault +): TSettledVersionedSelection { + const selectedEvents = filterEventsByRequestedVault( + filterEventsByAuthoritativeVersion(context.rawEvents, context.vaultMetadata, version), + requestedVault + ) + + return { + events: selectedEvents, + vaultIdentifiers: getVaultIdentifiers(selectedEvents) + } +} + +export async function getSettledAddressScopedContext(args: { + userAddress: string + fetchType: HoldingsEventFetchType + paginationMode: HoldingsEventPaginationMode +}): Promise { + const key = getContextKey(args) + const existing = inFlightSettledAddressScopedContexts.get(key) + + if (existing) { + debugLog('holdings-context', 'reusing in-flight settled address-scoped context', { key }) + return existing + } + + const request = (async () => { + const settledTimestamps = generateDailyTimestamps(holdingsConfig.historyDays, 1) + const latestSettledDayTimestamp = settledTimestamps[settledTimestamps.length - 1] ?? 0 + const maxTimestamp = toSettledDayTimestamp(latestSettledDayTimestamp) + const activityMaxTimestamp = maxTimestamp + CURRENT_DAY_LOOKAHEAD_SECONDS + const events = await fetchUserEvents( + args.userAddress, + 'all', + activityMaxTimestamp, + args.fetchType, + args.paginationMode + ) + const timeline = buildPositionTimeline(events.deposits, events.withdrawals, events.transfersIn, events.transfersOut) + const rawEvents = buildAddressScopedRawPnlEvents(events) + const rawVaultIdentifiers = timeline.length > 0 ? getUniqueVaults(timeline) : getVaultIdentifiers(rawEvents) + const baseVaultMetadata = + rawVaultIdentifiers.length > 0 ? await fetchMultipleVaultsMetadata(rawVaultIdentifiers) : new Map() + const vaultMetadata = await resolveNestedVaultAssetMetadata(baseVaultMetadata) + + return { + address: lowerCaseAddress(args.userAddress), + latestSettledDayTimestamp, + maxTimestamp, + events, + timeline, + hasActivity: timeline.length > 0, + rawEvents, + rawVaultIdentifiers, + vaultMetadata + } + })().finally(() => { + inFlightSettledAddressScopedContexts.delete(key) + }) + + inFlightSettledAddressScopedContexts.set(key, request) + return request +} + +export async function getSettledVersionedPpsContext(args: { + userAddress: string + version: VaultVersion + fetchType: HoldingsEventFetchType + paginationMode: HoldingsEventPaginationMode + requestedVault?: TRequestedVault + vaultIdentifiers?: TVaultIdentifier[] + context?: TSettledAddressScopedContext +}): Promise { + const key = getContextKey(args) + const existing = inFlightSettledVersionedPpsContexts.get(key) + + if (existing) { + debugLog('holdings-context', 'reusing in-flight settled versioned PPS context', { key }) + return existing + } + + const request = (async () => { + const context = + args.context ?? + (await getSettledAddressScopedContext({ + userAddress: args.userAddress, + fetchType: args.fetchType, + paginationMode: args.paginationMode + })) + const selection = selectVersionedEvents(context, args.version, args.requestedVault) + const selectedVaultIdentifiers = args.vaultIdentifiers ?? selection.vaultIdentifiers + const basePriceRequests = buildUnderlyingTokenRequests(selectedVaultIdentifiers, context.vaultMetadata) + const ppsIdentifiers = mergeVaultIdentifiers([ + ...selectedVaultIdentifiers, + ...getNestedVaultPpsIdentifiersFromPriceRequests(basePriceRequests, context.vaultMetadata) + ]) + const ppsData = ppsIdentifiers.length > 0 ? await fetchMultipleVaultsPPS(ppsIdentifiers) : new Map() + + return { + ...context, + selectedEvents: selection.events, + selectedVaultIdentifiers, + ppsIdentifiers, + ppsData + } + })().finally(() => { + inFlightSettledVersionedPpsContexts.delete(key) + }) + + inFlightSettledVersionedPpsContexts.set(key, request) + return request +} diff --git a/api/lib/holdings/services/staking.ts b/api/lib/holdings/services/staking.ts new file mode 100644 index 000000000..e60db5cae --- /dev/null +++ b/api/lib/holdings/services/staking.ts @@ -0,0 +1,97 @@ +// Mapping of staking vault address -> underlying vault address. +// Staking vault shares are 1:1 wrappers around the underlying vault shares. +const STAKING_TO_UNDERLYING_ENTRIES = [ + [ + '0xb98343536e584cf686427a54574567ba5bda8070', + { underlying: '0x42842754aBce504E12C20E434Af8960FDf85C833', chainId: 1 } + ], + [ + '0x6130e6cd924a40b24703407f246966d7435d4998', + { underlying: '0xbA61BaA1D96c2F4E25205B331306507BcAeA4677', chainId: 1 } + ], + [ + '0x7fd8af959b54a677a1d8f92265bd0714274c56a3', + { underlying: '0x790a60024bC3aea28385b60480f15a0771f26D09', chainId: 1 } + ], + [ + '0x28da6de3e804bddf0ad237cfa6048f2930d0b4dc', + { underlying: '0xf70B3F1eA3BFc659FFb8b27E84FAE7Ef38b5bD3b', chainId: 1 } + ], + [ + '0x107717c98c8125a94d3d2cc82b86a1b705f3a27c', + { underlying: '0x6E9455D109202b426169F0d8f01A3332DAE160f3', chainId: 1 } + ], + [ + '0x81d93531720d86f0491dee7d03f30b3b5ac24e59', + { underlying: '0x58900d761Ae3765B75DDFc235c1536B527F25d8F', chainId: 1 } + ], + [ + '0xd57aea3686d623da2dcebc87010a4f2f38ac7b15', + { underlying: '0x182863131F9a4630fF9E27830d945B1413e347E8', chainId: 1 } + ], + [ + '0x622fa41799406b120f9a40da843d358b7b2cfee3', + { underlying: '0xBe53A109B494E5c9f97b9Cd39Fe969BE68BF6204', chainId: 1 } + ], + [ + '0x128e72dfd8b00cbf9d12cb75e846ac87b83ddfc9', + { underlying: '0x028eC7330ff87667b6dfb0D94b954c820195336c', chainId: 1 } + ], + [ + '0x5943f7090282eb66575662eadf7c60a717a7ce4d', + { underlying: '0xc56413869c6CDf96496f2b1eF801fEDBdFA7dDB0', chainId: 1 } + ], + [ + '0xb61f8fff8dd8c438e0d61c07b5536ce3d728f660', + { underlying: '0x93cF0b02D0A2B61551d107378AFf60CEAe40c342', chainId: 1 } + ], + [ + '0xf719b2d3925cc445d2bb67fa12963265e224fa11', + { underlying: '0xFCa9Ab2996e7b010516adCC575eB63de4f4fa47A', chainId: 1 } + ], + [ + '0x97a597cbca514afcc29cd300f04f98d9dbaa3624', + { underlying: '0x6A5694C1b37fFA30690b6b60D8Cf89c937d408aD', chainId: 1 } + ], + [ + '0x38e3d865e34f7367a69f096c80a4fc329db38bf4', + { underlying: '0x92545bCE636E6eE91D88D2D017182cD0bd2fC22e', chainId: 1 } + ], + [ + '0x8e2485942b399ea41f3c910c1bb8567128f79859', + { underlying: '0xAc37729B76db6438CE62042AE1270ee574CA7571', chainId: 1 } + ], + [ + '0x71c3223d6f836f84caa7ab5a68aab6ece21a9f3b', + { underlying: '0xBF319dDC2Edc1Eb6FDf9910E39b37Be221C8805F', chainId: 1 } + ] +] as const + +export const STAKING_TO_UNDERLYING: Record = Object.fromEntries( + STAKING_TO_UNDERLYING_ENTRIES.map(([stakingAddress, config]) => [stakingAddress.toLowerCase(), config]) +) + +export const UNDERLYING_TO_STAKING: Record = Object.fromEntries( + STAKING_TO_UNDERLYING_ENTRIES.map(([stakingAddress, config]) => [ + `${config.chainId}:${config.underlying.toLowerCase()}`, + stakingAddress.toLowerCase() + ]) +) + +export function isStakingVault(chainId: number, address: string): boolean { + const config = STAKING_TO_UNDERLYING[address.toLowerCase()] + return config?.chainId === chainId +} + +export function getUnderlyingVault(stakingAddress: string): { underlying: string; chainId: number } | undefined { + return STAKING_TO_UNDERLYING[stakingAddress.toLowerCase()] +} + +export function getFamilyVaultAddress(chainId: number, address: string): string { + const config = getUnderlyingVault(address) + return config && config.chainId === chainId ? config.underlying.toLowerCase() : address.toLowerCase() +} + +export function getStakingVaultAddress(chainId: number, underlyingVaultAddress: string): string | null { + return UNDERLYING_TO_STAKING[`${chainId}:${underlyingVaultAddress.toLowerCase()}`] ?? null +} diff --git a/api/lib/holdings/services/vaults.test.ts b/api/lib/holdings/services/vaults.test.ts new file mode 100644 index 000000000..e4b23a877 --- /dev/null +++ b/api/lib/holdings/services/vaults.test.ts @@ -0,0 +1,181 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +const UNDERLYING_VAULT = '0xbe53a109b494e5c9f97b9cd39fe969be68bf6204' +const STAKING_VAULT = '0x622fa41799406b120f9a40da843d358b7b2cfee3' + +function createVaultListResponse(): Response { + return new Response( + JSON.stringify([ + { + address: '0xBe53A109B494E5c9f97b9Cd39Fe969BE68BF6204', + apiVersion: '3.0.2', + chainId: 1, + symbol: 'yvUSDC', + decimals: 6, + v3: true, + asset: { + address: '0xA0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + symbol: 'USDC', + decimals: 6 + }, + staking: { + address: '0x622fa41799406b120f9a40da843d358b7b2cfee3', + available: true + } + } + ]), + { + status: 200, + headers: { 'content-type': 'application/json' } + } + ) +} + +function createVaultSnapshotResponse(): Response { + return new Response( + JSON.stringify({ + address: '0xBe53A109B494E5c9f97b9Cd39Fe969BE68BF6204', + apiVersion: '3.0.2', + chainId: 1, + symbol: 'yvUSDC', + decimals: 6, + v3: true, + asset: { + address: '0xA0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + symbol: 'USDC', + decimals: 6 + }, + staking: { + address: '0x622fa41799406b120f9a40da843d358b7b2cfee3', + available: true + } + }), + { + status: 200, + headers: { 'content-type': 'application/json' } + } + ) +} + +async function importVaultsModule() { + vi.resetModules() + return import('./vaults') +} + +describe('fetchMultipleVaultsMetadata', () => { + afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllGlobals() + }) + + it('retries transient vault list failures and loads metadata', async () => { + const fetchStub = vi + .fn() + .mockRejectedValueOnce(Object.assign(new Error('Unable to connect'), { code: 'ConnectionRefused' })) + .mockResolvedValue(createVaultListResponse()) + + vi.stubGlobal('fetch', fetchStub) + + const { fetchMultipleVaultsMetadata } = await importVaultsModule() + const metadata = await fetchMultipleVaultsMetadata([ + { chainId: 1, vaultAddress: '0xbe53a109b494e5c9f97b9cd39fe969be68bf6204' } + ]) + + expect(fetchStub).toHaveBeenCalledTimes(2) + expect(metadata.get(`1:${UNDERLYING_VAULT}`)?.token.symbol).toBe('USDC') + }) + + it('falls back to per-vault snapshots when the global list endpoint is unavailable', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + const fetchStub = vi.fn(async (input: string | URL | Request) => { + const url = String(input) + + if (url.includes('/list/vaults?origin=yearn')) { + throw Object.assign(new Error('socket closed'), { code: 'ECONNRESET' }) + } + + if (url.includes(`/snapshot/1/${UNDERLYING_VAULT}`)) { + return createVaultSnapshotResponse() + } + + throw new Error(`Unexpected URL: ${url}`) + }) + + vi.stubGlobal('fetch', fetchStub) + + const { fetchMultipleVaultsMetadata } = await importVaultsModule() + const metadata = await fetchMultipleVaultsMetadata([{ chainId: 1, vaultAddress: UNDERLYING_VAULT }]) + + expect(metadata.get(`1:${UNDERLYING_VAULT}`)?.token.address).toBe('0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48') + expect(fetchStub).toHaveBeenCalledTimes(4) + }) + + it('builds staking metadata from the underlying snapshot fallback', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + const fetchStub = vi.fn(async (input: string | URL | Request) => { + const url = String(input) + + if (url.includes('/list/vaults?origin=yearn')) { + throw Object.assign(new Error('socket closed'), { code: 'ECONNRESET' }) + } + + if (url.includes(`/snapshot/1/${UNDERLYING_VAULT}`)) { + return createVaultSnapshotResponse() + } + + throw new Error(`Unexpected URL: ${url}`) + }) + + vi.stubGlobal('fetch', fetchStub) + + const { fetchMultipleVaultsMetadata } = await importVaultsModule() + const metadata = await fetchMultipleVaultsMetadata([{ chainId: 1, vaultAddress: STAKING_VAULT }]) + + expect(metadata.get(`1:${STAKING_VAULT}`)).toEqual({ + address: STAKING_VAULT, + chainId: 1, + version: 'v3', + category: 'stable', + isHidden: false, + token: { + address: UNDERLYING_VAULT, + symbol: 'yvUSDC', + decimals: 6 + }, + decimals: 6 + }) + }) + + it('retries the global vault list after snapshot fallback seeded the metadata cache', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + let listAttempts = 0 + const fetchStub = vi.fn(async (input: string | URL | Request) => { + const url = String(input) + + if (url.includes('/list/vaults?origin=yearn')) { + listAttempts += 1 + if (listAttempts === 1) { + throw Object.assign(new Error('socket closed'), { code: 'ECONNRESET' }) + } + return createVaultListResponse() + } + + if (url.includes(`/snapshot/1/${UNDERLYING_VAULT}`)) { + return createVaultSnapshotResponse() + } + + throw new Error(`Unexpected URL: ${url}`) + }) + + vi.stubGlobal('fetch', fetchStub) + + const { fetchMultipleVaultsMetadata } = await importVaultsModule() + + const first = await fetchMultipleVaultsMetadata([{ chainId: 1, vaultAddress: UNDERLYING_VAULT }]) + expect(first.get(`1:${UNDERLYING_VAULT}`)?.token.symbol).toBe('USDC') + + const second = await fetchMultipleVaultsMetadata([{ chainId: 1, vaultAddress: UNDERLYING_VAULT }]) + expect(second.get(`1:${UNDERLYING_VAULT}`)?.token.symbol).toBe('USDC') + expect(listAttempts).toBe(2) + }) +}) diff --git a/api/lib/holdings/services/vaults.ts b/api/lib/holdings/services/vaults.ts new file mode 100644 index 000000000..1c00588fe --- /dev/null +++ b/api/lib/holdings/services/vaults.ts @@ -0,0 +1,564 @@ +import { holdingsConfig } from '../config' +import type { VaultMetadata } from '../types' +import { debugError, debugLog } from './debug' +import { getUnderlyingVault, isStakingVault } from './staking' + +interface KongVault { + address: string + apiVersion?: string + chainId: number + symbol: string + decimals: number + v3?: boolean + category?: string | null + isHidden?: boolean + asset: { + address: string + symbol: string + decimals: number + } + staking?: { + address: string | null + available: boolean + } +} + +interface KongVaultSnapshot { + address: string + apiVersion?: string + chainId: number + symbol?: string + decimals?: number + v3?: boolean + meta?: { + category?: string | null + isHidden?: boolean + } | null + asset?: { + address: string + symbol: string + decimals: number + } + staking?: { + address?: string | null + available?: boolean + } | null +} + +type TVaultListState = { + vaultListCache: Map | null + stakingToVaultMap: Map | null + hasLoadedGlobalVaultList: boolean + loadPromise: Promise | null +} + +type TKongMetadataError = Error & { + code?: string + status?: number +} + +const RETRYABLE_ERROR_CODES = new Set([ + 'ECONNRESET', + 'ECONNREFUSED', + 'ConnectionRefused', + 'ETIMEDOUT', + 'EAI_AGAIN', + 'UND_ERR_SOCKET', + 'UND_ERR_CONNECT_TIMEOUT', + 'UND_ERR_HEADERS_TIMEOUT', + 'UND_ERR_ABORTED' +]) +const RETRYABLE_STATUS_CODES = new Set([408, 425, 429, 500, 502, 503, 504]) +const DEFAULT_TIMEOUT_MS = 4_000 +const DEFAULT_MAX_RETRIES = 2 +const DEFAULT_RETRY_DELAY_MS = 200 +const SNAPSHOT_CONCURRENCY = 3 +const KNOWN_STABLECOIN_SYMBOLS = new Set([ + 'USDC', + 'USDT', + 'DAI', + 'FRAX', + 'LUSD', + 'TUSD', + 'USDE', + 'SUSDE', + 'GHO', + 'CRVUSD', + 'USD0', + 'PYUSD', + 'USDP', + 'SDAI', + 'AUSD', + 'BOLD' +]) +const vaultListState: TVaultListState = { + vaultListCache: null, + stakingToVaultMap: null, + hasLoadedGlobalVaultList: false, + loadPromise: null +} + +function normalizeVaultCategory(category?: string | null): 'stable' | 'volatile' | null { + const normalized = String(category ?? '') + .trim() + .toLowerCase() + + if (!normalized) { + return null + } + + if (normalized === 'stablecoin') { + return 'stable' + } + + if (normalized === 'volatile' || normalized === 'auto') { + return 'volatile' + } + + return null +} + +function deriveVaultCategory(symbols: Array): 'stable' | 'volatile' { + const haystack = symbols + .filter((symbol): symbol is string => Boolean(symbol)) + .join(' ') + .toUpperCase() + + for (const stable of KNOWN_STABLECOIN_SYMBOLS) { + if (haystack.includes(stable)) { + return 'stable' + } + } + + return 'volatile' +} + +function resolveVaultCategory(args: { + category?: string | null + assetSymbol?: string + vaultSymbol?: string +}): 'stable' | 'volatile' { + return normalizeVaultCategory(args.category) ?? deriveVaultCategory([args.assetSymbol, args.vaultSymbol]) +} + +function wait(delayMs: number): Promise { + return new Promise((resolve) => setTimeout(resolve, delayMs)) +} + +function isRetryableError(error: unknown): boolean { + const kongError = error as Partial + const code = typeof kongError?.code === 'string' ? kongError.code : null + const status = typeof kongError?.status === 'number' ? kongError.status : null + const message = error instanceof Error ? error.message.toLowerCase() : '' + + return ( + (code !== null && RETRYABLE_ERROR_CODES.has(code)) || + (status !== null && RETRYABLE_STATUS_CODES.has(status)) || + message.includes('socket connection was closed unexpectedly') || + message.includes('unable to connect') || + message.includes('timed out') || + message.includes('timeout') + ) +} + +function buildMetadataMaps(vaults: KongVault[]): { + vaultListCache: Map + stakingToVaultMap: Map +} { + return vaults.reduce<{ + vaultListCache: Map + stakingToVaultMap: Map + }>( + (maps, vault) => { + const version = inferVaultVersion(vault) + const metadata: VaultMetadata = { + address: vault.address.toLowerCase(), + chainId: vault.chainId, + version, + isHidden: Boolean(vault.isHidden), + category: resolveVaultCategory({ + category: vault.category, + assetSymbol: vault.asset.symbol, + vaultSymbol: vault.symbol + }), + token: { + address: vault.asset.address.toLowerCase(), + symbol: vault.asset.symbol, + decimals: vault.asset.decimals + }, + decimals: vault.decimals + } + + const key = `${vault.chainId}:${vault.address.toLowerCase()}` + maps.vaultListCache.set(key, metadata) + + if (vault.staking?.address) { + const stakingKey = `${vault.chainId}:${vault.staking.address.toLowerCase()}` + const stakingMetadata: VaultMetadata = { + address: vault.staking.address.toLowerCase(), + chainId: vault.chainId, + version, + isHidden: metadata.isHidden, + category: metadata.category, + token: { + address: vault.address.toLowerCase(), + symbol: vault.symbol, + decimals: vault.decimals + }, + decimals: vault.decimals + } + maps.stakingToVaultMap.set(stakingKey, stakingMetadata) + } + + return maps + }, + { + vaultListCache: new Map(), + stakingToVaultMap: new Map() + } + ) +} + +function inferVaultVersion(vault: { apiVersion?: string; v3?: boolean }): 'v2' | 'v3' { + if (vault.v3 === true) { + return 'v3' + } + + return typeof vault.apiVersion === 'string' && vault.apiVersion.startsWith('3') ? 'v3' : 'v2' +} + +function chunkItems(items: T[], chunkSize: number): T[][] { + return Array.from({ length: Math.ceil(items.length / chunkSize) }, (_value, index) => + items.slice(index * chunkSize, index * chunkSize + chunkSize) + ) +} + +function ensureMetadataMaps(): { + vaultListCache: Map + stakingToVaultMap: Map +} { + if (vaultListState.vaultListCache === null) { + vaultListState.vaultListCache = new Map() + } + + if (vaultListState.stakingToVaultMap === null) { + vaultListState.stakingToVaultMap = new Map() + } + + return { + vaultListCache: vaultListState.vaultListCache, + stakingToVaultMap: vaultListState.stakingToVaultMap + } +} + +function buildMetadataFromSnapshot(snapshot: KongVaultSnapshot): VaultMetadata | null { + if (!snapshot.asset) { + return null + } + + return { + address: snapshot.address.toLowerCase(), + chainId: snapshot.chainId, + version: inferVaultVersion(snapshot), + isHidden: Boolean(snapshot.meta?.isHidden), + category: resolveVaultCategory({ + category: snapshot.meta?.category, + assetSymbol: snapshot.asset.symbol, + vaultSymbol: snapshot.symbol + }), + token: { + address: snapshot.asset.address.toLowerCase(), + symbol: snapshot.asset.symbol, + decimals: snapshot.asset.decimals + }, + decimals: snapshot.decimals ?? 18 + } +} + +function buildStakingMetadataFromSnapshot(stakingAddress: string, snapshot: KongVaultSnapshot): VaultMetadata | null { + if (!snapshot.symbol || snapshot.decimals === undefined) { + return null + } + + return { + address: stakingAddress.toLowerCase(), + chainId: snapshot.chainId, + version: inferVaultVersion(snapshot), + isHidden: Boolean(snapshot.meta?.isHidden), + category: resolveVaultCategory({ + category: snapshot.meta?.category, + assetSymbol: snapshot.asset?.symbol, + vaultSymbol: snapshot.symbol + }), + token: { + address: snapshot.address.toLowerCase(), + symbol: snapshot.symbol, + decimals: snapshot.decimals + }, + decimals: snapshot.decimals + } +} + +function storeMetadata(key: string, metadata: VaultMetadata): void { + ensureMetadataMaps().vaultListCache.set(key, metadata) +} + +function storeStakingMetadata(key: string, metadata: VaultMetadata): void { + ensureMetadataMaps().stakingToVaultMap.set(key, metadata) +} + +async function fetchVaultList(attempt = 0): Promise { + const url = `${holdingsConfig.kongBaseUrl}/api/rest/list/vaults?origin=yearn` + debugLog('vaults', 'fetching global vault list', { attempt: attempt + 1, url }) + + try { + const response = await fetch(url, { signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS) }) + if (!response.ok) { + const error = new Error(`Kong vault list request failed: ${response.status}`) as TKongMetadataError + error.status = response.status + throw error + } + + const vaults = (await response.json()) as KongVault[] + debugLog('vaults', 'fetched global vault list', { attempt: attempt + 1, count: vaults.length }) + return vaults + } catch (error) { + if (attempt >= DEFAULT_MAX_RETRIES || !isRetryableError(error)) { + debugError('vaults', 'global vault list fetch failed', error, { attempt: attempt + 1 }) + throw error + } + + debugError('vaults', 'retrying global vault list fetch', error, { nextAttempt: attempt + 2 }) + await wait(DEFAULT_RETRY_DELAY_MS * 2 ** attempt) + return fetchVaultList(attempt + 1) + } +} + +async function fetchVaultSnapshot(chainId: number, vaultAddress: string, attempt = 0): Promise { + const url = `${holdingsConfig.kongBaseUrl}/api/rest/snapshot/${chainId}/${vaultAddress}` + debugLog('vaults', 'fetching vault snapshot fallback', { + attempt: attempt + 1, + chainId, + vaultAddress + }) + + try { + const response = await fetch(url, { signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS) }) + + if (!response.ok) { + const error = new Error( + `Kong snapshot request failed: ${response.status} for ${vaultAddress}` + ) as TKongMetadataError + error.status = response.status + throw error + } + + const snapshot = (await response.json()) as KongVaultSnapshot + debugLog('vaults', 'fetched vault snapshot fallback', { + attempt: attempt + 1, + chainId, + vaultAddress, + hasAsset: snapshot.asset !== undefined + }) + return snapshot + } catch (error) { + if (attempt >= DEFAULT_MAX_RETRIES || !isRetryableError(error)) { + debugError('vaults', 'vault snapshot fallback failed', error, { + attempt: attempt + 1, + chainId, + vaultAddress + }) + throw error + } + + debugError('vaults', 'retrying vault snapshot fallback', error, { + nextAttempt: attempt + 2, + chainId, + vaultAddress + }) + await wait(DEFAULT_RETRY_DELAY_MS * 2 ** attempt) + return fetchVaultSnapshot(chainId, vaultAddress, attempt + 1) + } +} + +async function fetchFallbackMetadataForVault( + chainId: number, + vaultAddress: string +): Promise<{ key: string; metadata: VaultMetadata } | null> { + const normalizedAddress = vaultAddress.toLowerCase() + + if (isStakingVault(chainId, normalizedAddress)) { + const underlyingConfig = getUnderlyingVault(normalizedAddress) + + if (!underlyingConfig || underlyingConfig.chainId !== chainId) { + return null + } + + const snapshot = await fetchVaultSnapshot(chainId, underlyingConfig.underlying.toLowerCase()) + const stakingMetadata = buildStakingMetadataFromSnapshot(normalizedAddress, snapshot) + + if (!stakingMetadata) { + return null + } + + storeStakingMetadata(`${chainId}:${normalizedAddress}`, stakingMetadata) + return { + key: `${chainId}:${normalizedAddress}`, + metadata: stakingMetadata + } + } + + const snapshot = await fetchVaultSnapshot(chainId, normalizedAddress) + const metadata = buildMetadataFromSnapshot(snapshot) + + if (!metadata) { + return null + } + + const key = `${chainId}:${normalizedAddress}` + storeMetadata(key, metadata) + + if (snapshot.staking?.address) { + const stakingAddress = snapshot.staking.address.toLowerCase() + const stakingMetadata = buildStakingMetadataFromSnapshot(stakingAddress, snapshot) + + if (stakingMetadata) { + storeStakingMetadata(`${chainId}:${stakingAddress}`, stakingMetadata) + } + } + + return { key, metadata } +} + +async function fetchFallbackMetadata( + vaults: Array<{ chainId: number; vaultAddress: string }> +): Promise> { + debugLog('vaults', 'using snapshot fallback for metadata', { requested: vaults.length }) + const uniqueVaults = Array.from( + new Map(vaults.map((vault) => [`${vault.chainId}:${vault.vaultAddress.toLowerCase()}`, vault])).values() + ) + + const results = await chunkItems(uniqueVaults, SNAPSHOT_CONCURRENCY).reduce< + Promise> + >(async (allResultsPromise, batch) => { + const allResults = await allResultsPromise + const batchResults = await Promise.allSettled( + batch.map(({ chainId, vaultAddress }) => fetchFallbackMetadataForVault(chainId, vaultAddress)) + ) + + const resolvedResults = batchResults.reduce>((entries, result) => { + if (result.status === 'rejected') { + console.error('[Kong] Failed to fetch fallback vault metadata:', result.reason) + debugError('vaults', 'fallback metadata fetch failed', result.reason) + return entries + } + + if (result.value === null) { + return entries + } + + entries.push(result.value) + return entries + }, []) + + return [...allResults, ...resolvedResults] + }, Promise.resolve([])) + + return results.reduce>((map, { key, metadata }) => { + map.set(key, metadata) + return map + }, new Map()) +} + +async function loadVaultList(): Promise { + if ( + vaultListState.hasLoadedGlobalVaultList && + vaultListState.vaultListCache !== null && + vaultListState.stakingToVaultMap !== null + ) { + return + } + + if (vaultListState.loadPromise !== null) { + return vaultListState.loadPromise + } + + vaultListState.loadPromise = fetchVaultList() + .then((vaults) => { + const maps = buildMetadataMaps(vaults) + vaultListState.vaultListCache = maps.vaultListCache + vaultListState.stakingToVaultMap = maps.stakingToVaultMap + vaultListState.hasLoadedGlobalVaultList = true + debugLog('vaults', 'stored global vault metadata maps', { + vaults: maps.vaultListCache.size, + stakingVaults: maps.stakingToVaultMap.size + }) + }) + .catch((error) => { + console.error('[Kong] Error fetching vault list:', error) + debugError('vaults', 'global vault metadata load failed', error) + + const message = error instanceof Error ? error.message : String(error) + throw new Error(`Failed to load vault metadata from Kong: ${message}`) + }) + .finally(() => { + vaultListState.loadPromise = null + }) + + return vaultListState.loadPromise +} + +export async function fetchVaultMetadata(chainId: number, vaultAddress: string): Promise { + const metadata = await fetchMultipleVaultsMetadata([{ chainId, vaultAddress }]) + return metadata.get(`${chainId}:${vaultAddress.toLowerCase()}`) ?? null +} + +export async function fetchMultipleVaultsMetadata( + vaults: Array<{ chainId: number; vaultAddress: string }>, + options?: { skipSnapshotFallback?: boolean } +): Promise> { + debugLog('vaults', 'resolving metadata for request', { requested: vaults.length }) + const loadError = await loadVaultList() + .then(() => null) + .catch((error) => error as Error) + + const results = vaults.reduce>((results, { chainId, vaultAddress }) => { + const key = `${chainId}:${vaultAddress.toLowerCase()}` + + if (vaultListState.vaultListCache?.has(key)) { + results.set(key, vaultListState.vaultListCache!.get(key)!) + return results + } + + if (vaultListState.stakingToVaultMap?.has(key)) { + results.set(key, vaultListState.stakingToVaultMap!.get(key)!) + } + + return results + }, new Map()) + + const missingVaults = vaults.filter( + ({ chainId, vaultAddress }) => !results.has(`${chainId}:${vaultAddress.toLowerCase()}`) + ) + + if (missingVaults.length > 0 && !options?.skipSnapshotFallback) { + debugLog('vaults', 'metadata missing from global cache, falling back to snapshots', { + missing: missingVaults.length + }) + const fallbackResults = await fetchFallbackMetadata(missingVaults) + fallbackResults.forEach((metadata, key) => { + results.set(key, metadata) + }) + } + + if (results.size === 0 && loadError && !options?.skipSnapshotFallback) { + throw loadError + } + + debugLog('vaults', 'resolved metadata for request', { + requested: vaults.length, + resolved: results.size, + loadError: loadError?.message ?? null + }) + return results +} diff --git a/api/lib/holdings/storage/redis.ts b/api/lib/holdings/storage/redis.ts new file mode 100644 index 000000000..9bf5cb4c9 --- /dev/null +++ b/api/lib/holdings/storage/redis.ts @@ -0,0 +1,81 @@ +import { Redis } from '@upstash/redis' +import { holdingsConfig } from '../config' + +const holdingsRedisState = { + client: null as Redis | null, + disabled: false, + initializationPromise: null as Promise | null +} + +function hasRedisConfig(): boolean { + return Boolean(holdingsConfig.redisUrl && holdingsConfig.redisToken) +} + +function shouldDisableRedis(error: unknown): boolean { + const message = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase() + return message.includes('unauthorized') || message.includes('invalid token') || message.includes('forbidden') +} + +function disableHoldingsRedis(reason: string, error?: unknown): void { + holdingsRedisState.disabled = true + holdingsRedisState.client = null + console.error(`[Holdings Redis] Disabling Redis-backed storage: ${reason}`, error ?? '') +} + +export function handleHoldingsRedisError(reason: string, error: unknown): void { + if (shouldDisableRedis(error)) { + disableHoldingsRedis(reason, error) + return + } + + console.error(`[Holdings Redis] ${reason}:`, error) +} + +export function isHoldingsStorageEnabled(): boolean { + return hasRedisConfig() && !holdingsRedisState.disabled +} + +export function getHoldingsRedisClient(): Redis | null { + if (!isHoldingsStorageEnabled()) { + return null + } + + if (!holdingsRedisState.client) { + holdingsRedisState.client = new Redis({ + url: holdingsConfig.redisUrl as string, + token: holdingsConfig.redisToken as string + }) + } + + return holdingsRedisState.client +} + +export async function initializeHoldingsStorage(): Promise { + const redis = getHoldingsRedisClient() + if (!redis) { + console.log('[Holdings Redis] No Redis configured, skipping storage initialization') + return + } + + try { + await redis.ping() + console.log('[Holdings Redis] Storage initialized successfully') + } catch (error) { + handleHoldingsRedisError('storage initialization failed', error) + } +} + +export function ensureHoldingsStorageInitialized(): Promise { + if (!isHoldingsStorageEnabled()) { + return Promise.resolve() + } + + if (!holdingsRedisState.initializationPromise) { + holdingsRedisState.initializationPromise = initializeHoldingsStorage().catch((error) => { + holdingsRedisState.initializationPromise = null + throw error + }) + } + + return holdingsRedisState.initializationPromise +} diff --git a/api/lib/holdings/types.ts b/api/lib/holdings/types.ts new file mode 100644 index 000000000..6828abc64 --- /dev/null +++ b/api/lib/holdings/types.ts @@ -0,0 +1,136 @@ +export interface ChainConfig { + id: number + name: string + defillamaPrefix: string +} + +export const SUPPORTED_CHAINS: ChainConfig[] = [ + { id: 1, name: 'ethereum', defillamaPrefix: 'ethereum' }, + { id: 10, name: 'optimism', defillamaPrefix: 'optimism' }, + { id: 250, name: 'fantom', defillamaPrefix: 'fantom' }, + { id: 8453, name: 'base', defillamaPrefix: 'base' }, + { id: 42161, name: 'arbitrum', defillamaPrefix: 'arbitrum' }, + { id: 137, name: 'polygon', defillamaPrefix: 'polygon' }, + { id: 747474, name: 'katana', defillamaPrefix: 'katana' } +] + +export interface DepositEvent { + id: string + vaultAddress: string + chainId: number + blockNumber: number + blockTimestamp: number + logIndex: number + transactionHash: string + transactionFrom: string + owner: string + sender: string + assets: string + shares: string +} + +export interface WithdrawEvent { + id: string + vaultAddress: string + chainId: number + blockNumber: number + blockTimestamp: number + logIndex: number + transactionHash: string + transactionFrom: string + owner: string + assets: string + shares: string +} + +export interface V2DepositEvent { + id: string + vaultAddress: string + chainId: number + blockNumber: number + blockTimestamp: number + logIndex: number + transactionHash: string + transactionFrom: string + recipient: string + amount: string + shares: string +} + +export interface V2WithdrawEvent { + id: string + vaultAddress: string + chainId: number + blockNumber: number + blockTimestamp: number + logIndex: number + transactionHash: string + transactionFrom: string + recipient: string + amount: string + shares: string +} + +export interface TransferEvent { + id: string + vaultAddress: string + chainId: number + blockNumber: number + blockTimestamp: number + logIndex: number + transactionHash: string + transactionFrom: string + sender: string + receiver: string + value: string +} + +export interface VaultMetadata { + address: string + chainId: number + version: 'v2' | 'v3' + category: 'stable' | 'volatile' + isHidden?: boolean + token: { + address: string + symbol: string + decimals: number + } + decimals: number +} + +export interface KongPPSDataPoint { + time: number + component: string + value: string +} + +export interface DefiLlamaPricePoint { + timestamp: number + price: number + confidence: number +} + +export interface DefiLlamaBatchResponse { + coins: { + [key: string]: { + symbol: string + prices: DefiLlamaPricePoint[] + } + } +} + +export interface UserEvents { + deposits: DepositEvent[] + withdrawals: WithdrawEvent[] + transfersIn: TransferEvent[] + transfersOut: TransferEvent[] +} + +export interface TimelineEvent { + vaultAddress: string + chainId: number + blockNumber: number + blockTimestamp: number + sharesChange: bigint +} diff --git a/api/server.ts b/api/server.ts index 2dc345b45..2962b2e1a 100644 --- a/api/server.ts +++ b/api/server.ts @@ -1,12 +1,40 @@ import { serve } from 'bun' +import { normalizeEnsoRouteResponse } from '../src/components/pages/vaults/hooks/solvers/ensoRoute' import type { TTenderlyFundRequest, TTenderlyIncreaseTimeRequest, TTenderlyRevertRequest, TTenderlySnapshotRequest } from '../src/components/shared/types/tenderly' +import { ENSO_BALANCES_CACHE_CONTROL } from './enso/cache' +import { + clearUserCache, + getHistoricalHoldingsChart, + getHoldingsActivity, + getHoldingsBreakdown, + getHoldingsProtocolReturnHistory, + getHoldingsTotalsCacheVersion, + type HoldingsActivityTypeFilter, + type HoldingsEventFetchType, + type HoldingsEventPaginationMode, + type HoldingsHistoryDenomination, + type HoldingsHistoryTimeframe, + initializeHoldingsStorage, + isHoldingsStorageEnabled, + type VaultVersion, + validateConfig +} from './lib/holdings' +import { invalidateVaults, type VaultIdentifier } from './lib/holdings/services/cache' +import { + createHoldingsDebugContext, + debugError, + debugLog, + isHoldingsDebugRequested, + withHoldingsDebugContext +} from './lib/holdings/services/debug' +import { fetchRecentAddressScopedActivityEvents } from './lib/holdings/services/graphql' +import { getHoldingsProgress, startHoldingsProgress, updateHoldingsProgress } from './lib/holdings/services/progress' import { getVaultDecimals } from './optimization/_lib/assetLogos' -import { OPTIMIZATION_GET_CORS_HEADERS, OPTIMIZATION_POST_CORS_HEADERS } from './optimization/_lib/cors' import { fetchAlignedEvents } from './optimization/_lib/envio' import { parseExplainMetadata } from './optimization/_lib/explain-parse' import { @@ -28,7 +56,7 @@ import { import { buildTenderlyAdminAccessDeniedResponse } from './tenderlyAccess' const ENSO_API_BASE = 'https://api.enso.finance' -const DEFAULT_API_SERVER_PORT = '3001' +const DEFAULT_API_PORT = 3001 const YVUSD_APR_SERVICE_API = ( process.env.YVUSD_APR_SERVICE_API || 'https://yearn-yvusd-apr-service.vercel.app/api/aprs' ).replace(/\/$/, '') @@ -37,19 +65,18 @@ function isHistoryQueryEnabled(historyParam: string | null): boolean { return historyParam === '1' || historyParam === 'true' } -function resolveApiServerPort(env: NodeJS.ProcessEnv): number { - const configuredPort = env.API_SERVER_PORT - if (configuredPort) { - const parsedConfiguredPort = Number(configuredPort) - if (Number.isInteger(parsedConfiguredPort) && parsedConfiguredPort > 0) { - return parsedConfiguredPort - } +function resolveApiPort(env: NodeJS.ProcessEnv): number { + const rawPort = env.API_PORT?.trim() || env.API_SERVER_PORT?.trim() || String(DEFAULT_API_PORT) + const port = Number(rawPort) + + if (!Number.isInteger(port) || port <= 0) { + throw new Error(`Invalid API port value: ${rawPort}`) } - return Number(DEFAULT_API_SERVER_PORT) + return port } -const API_SERVER_PORT = resolveApiServerPort(process.env) +const API_PORT = resolveApiPort(process.env) type TTenderlyJsonRpcSuccess = { id: string | number | null @@ -67,22 +94,6 @@ type TTenderlyJsonRpcError = { } } -function withCorsHeaders(headers: HeadersInit | undefined, corsHeaders: Readonly>): HeadersInit { - return { ...corsHeaders, ...(headers ?? {}) } -} - -function jsonWithCors( - body: unknown, - status: number, - corsHeaders: Readonly>, - headers?: HeadersInit -): Response { - return Response.json(body, { - status, - headers: withCorsHeaders(headers, corsHeaders) - }) -} - async function handleYvUsdAprs(req: Request): Promise { if (req.method !== 'GET') { return Response.json({ error: 'Method not allowed' }, { status: 405 }) @@ -121,6 +132,220 @@ async function handleYvUsdAprs(req: Request): Promise { } } +const CORS_HEADERS = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type, Authorization, x-admin-secret' +} + +function withCors(response: Response): Response { + const newHeaders = new Headers(response.headers) + for (const [key, value] of Object.entries(CORS_HEADERS)) { + newHeaders.set(key, value) + } + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers: newHeaders + }) +} + +function handleCorsPreFlight(): Response { + return new Response(null, { + status: 204, + headers: CORS_HEADERS + }) +} + +async function handleHoldingsProgress(req: Request): Promise { + const url = new URL(req.url) + const progress = await getHoldingsProgress(url.searchParams.get('id')) + + if (!progress) { + return Response.json({ error: 'Progress not found', status: 404 }, { status: 404 }) + } + + return Response.json(progress, { + headers: { + 'Cache-Control': 'no-store' + } + }) +} + +function isValidAddress(address: string): boolean { + return /^0x[a-fA-F0-9]{40}$/.test(address) +} + +function parseVaultFilters(url: URL): Array<{ chainId: number; vaultAddress: string }> | null | undefined { + const vaults = url.searchParams.get('vaults') + + if (vaults !== null) { + const entries = vaults + .split(',') + .map((entry) => entry.trim()) + .filter(Boolean) + const parsedEntries = entries.map((entry) => { + const [entryChainId, entryVaultAddress] = entry.split(':') + const parsedChainId = Number(entryChainId) + + if ( + !entryChainId || + !entryVaultAddress || + !Number.isInteger(parsedChainId) || + !isValidAddress(entryVaultAddress) + ) { + return null + } + + return { chainId: parsedChainId, vaultAddress: entryVaultAddress } + }) + + if (parsedEntries.some((entry) => entry === null)) { + return null + } + + return parsedEntries.filter((entry): entry is { chainId: number; vaultAddress: string } => entry !== null) + } + + const vault = url.searchParams.get('vault') + if (vault === null) { + return undefined + } + + const chainId = url.searchParams.get('chainId') + if (!isValidAddress(vault) || !chainId || !Number.isInteger(Number(chainId))) { + return null + } + + return [{ chainId: Number(chainId), vaultAddress: vault }] +} + +interface InvalidateRequestBody { + vaults: Array<{ address: string; chainId: number }> +} + +function validateInvalidateBody(body: unknown): body is InvalidateRequestBody { + if (!body || typeof body !== 'object') return false + const candidate = body as Record + if (!Array.isArray(candidate.vaults) || candidate.vaults.length === 0) return false + + for (const vault of candidate.vaults) { + if (!vault || typeof vault !== 'object') return false + const value = vault as Record + if (typeof value.address !== 'string' || !isValidAddress(value.address)) return false + if (typeof value.chainId !== 'number' || !Number.isInteger(value.chainId)) return false + } + + return true +} + +function parseHoldingsEventFetchType(value: string | null): HoldingsEventFetchType { + return value === 'parallel' ? 'parallel' : 'seq' +} + +function parseHoldingsEventPaginationMode(value: string | null): HoldingsEventPaginationMode { + return value === 'all' ? 'all' : 'paged' +} + +function parseHoldingsHistoryDenomination(value: string | null): HoldingsHistoryDenomination { + return value === 'eth' ? 'eth' : 'usd' +} + +function parseHoldingsHistoryTimeframe(value: string | null): HoldingsHistoryTimeframe { + return value === 'all' ? 'all' : '1y' +} + +function parseHoldingsActivityLimit(value: string | null): number { + const parsed = Number(value) + + if (!Number.isFinite(parsed) || !Number.isInteger(parsed)) { + return 10 + } + + return Math.min(Math.max(parsed, 1), 50) +} + +function parseHoldingsActivityOffset(value: string | null): number { + const parsed = Number(value) + + if (!Number.isFinite(parsed) || !Number.isInteger(parsed)) { + return 0 + } + + return Math.max(parsed, 0) +} + +function parseHoldingsActivityType(value: string | null): HoldingsActivityTypeFilter { + return value === 'deposit' || + value === 'withdraw' || + value === 'stake' || + value === 'unstake' || + value === 'transfer' || + value === 'swap' + ? value + : 'all' +} + +function parseHoldingsActivityChainId(value: string | null): number | null { + const parsed = Number(value) + + return Number.isInteger(parsed) && parsed > 0 ? parsed : null +} + +function parseHoldingsActivityTimestamp(value: string | null): number | null { + if (!value) { + return null + } + + const parsed = Number(value) + + return Number.isInteger(parsed) && parsed >= 0 ? parsed : null +} + +function parseHoldingsActivityBoolean(value: string | null): boolean { + return value === 'true' || value === '1' +} + +function parsePositiveIntegerParam(value: string | null, fallback: number, max: number): number { + const parsed = Number(value) + + return Number.isInteger(parsed) && parsed > 0 ? Math.min(parsed, max) : fallback +} + +function parseNonNegativeIntegerParam(value: string | null): number { + const parsed = Number(value) + + return Number.isInteger(parsed) && parsed >= 0 ? parsed : 0 +} + +function parseUtcDateParam(value: string | null): number | null { + if (!value) { + return null + } + + const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value) + if (!match) { + return null + } + + const [, year, month, day] = match + const yearNumber = Number(year) + const monthNumber = Number(month) + const dayNumber = Number(day) + const utcDate = new Date(Date.UTC(yearNumber, monthNumber - 1, dayNumber)) + + if ( + utcDate.getUTCFullYear() !== yearNumber || + utcDate.getUTCMonth() !== monthNumber - 1 || + utcDate.getUTCDate() !== dayNumber + ) { + return null + } + + const timestamp = Math.floor(utcDate.getTime() / 1000) + return Number.isFinite(timestamp) ? timestamp : null +} + async function parseJsonBody(req: Request): Promise { try { return (await req.json()) as T @@ -282,6 +507,7 @@ async function handleEnsoRoute(req: Request): Promise { const tokenOut = url.searchParams.get('tokenOut') const amountIn = url.searchParams.get('amountIn') const slippage = url.searchParams.get('slippage') || '100' + const routingStrategy = url.searchParams.get('routingStrategy') const destinationChainId = url.searchParams.get('destinationChainId') const receiver = url.searchParams.get('receiver') @@ -310,6 +536,9 @@ async function handleEnsoRoute(req: Request): Promise { if (receiver) { params.set('receiver', receiver) } + if (routingStrategy) { + params.set('routingStrategy', routingStrategy) + } const ensoUrl = `${ENSO_API_BASE}/api/v1/shortcuts/route?${params}` @@ -327,7 +556,18 @@ async function handleEnsoRoute(req: Request): Promise { return Response.json(data, { status: response.status }) } - return Response.json(data) + const parsedChainId = Number(chainId) + const normalizedResponse = normalizeEnsoRouteResponse( + data, + response.status, + Number.isFinite(parsedChainId) ? parsedChainId : undefined + ) + + if (normalizedResponse.error) { + return Response.json(normalizedResponse.error, { status: normalizedResponse.error.statusCode }) + } + + return Response.json(normalizedResponse.route) } catch (error) { console.error('Error proxying Enso route request:', error) return Response.json({ error: 'Internal server error' }, { status: 500 }) @@ -337,6 +577,7 @@ async function handleEnsoRoute(req: Request): Promise { async function handleEnsoBalances(req: Request): Promise { const url = new URL(req.url) const eoaAddress = url.searchParams.get('eoaAddress') + const chainId = url.searchParams.get('chainId') if (!eoaAddress) { return Response.json({ error: 'Missing eoaAddress' }, { status: 400 }) @@ -351,7 +592,7 @@ async function handleEnsoBalances(req: Request): Promise { const params = new URLSearchParams({ eoaAddress, useEoa: 'true', - chainId: 'all' + chainId: chainId || 'all' }) const ensoUrl = `${ENSO_API_BASE}/api/v1/wallet/balances?${params}` @@ -375,7 +616,7 @@ async function handleEnsoBalances(req: Request): Promise { const data = await response.json() return Response.json(data, { headers: { - 'Cache-Control': 'public, s-maxage=60, stale-while-revalidate=300' + 'Cache-Control': ENSO_BALANCES_CACHE_CONTROL } }) } catch (error) { @@ -389,18 +630,14 @@ const ALIGNMENT_CACHE_CONTROL = 'public, s-maxage=60, stale-while-revalidate=30' const VAULT_STATE_CACHE_CONTROL = 'public, s-maxage=60, stale-while-revalidate=30' async function handleOptimizationChange(req: Request): Promise { - if (req.method === 'OPTIONS') { - return new Response(null, { status: 204, headers: OPTIMIZATION_GET_CORS_HEADERS }) - } - if (req.method !== 'GET') { - return jsonWithCors({ error: 'Method not allowed' }, 405, OPTIMIZATION_GET_CORS_HEADERS) + return Response.json({ error: 'Method not allowed' }, { status: 405 }) } try { const optimizations = await readOptimizations() if (!optimizations || optimizations.length === 0) { - return jsonWithCors({ error: 'No optimization data available' }, 404, OPTIMIZATION_GET_CORS_HEADERS) + return Response.json({ error: 'No optimization data available' }, { status: 404 }) } const url = new URL(req.url) @@ -411,99 +648,89 @@ async function handleOptimizationChange(req: Request): Promise { return optimization.vault.toLowerCase() === requestedVault.toLowerCase() }) if (selectedHistory.length === 0) { - return jsonWithCors( - { error: `Vault not found in optimization payload: ${requestedVault}` }, - 404, - OPTIMIZATION_GET_CORS_HEADERS - ) + return Response.json({ error: `Vault not found in optimization payload: ${requestedVault}` }, { status: 404 }) } - return jsonWithCors(selectedHistory, 200, OPTIMIZATION_GET_CORS_HEADERS, { - 'Cache-Control': CHANGE_CACHE_CONTROL + return Response.json(selectedHistory, { + headers: { + 'Cache-Control': CHANGE_CACHE_CONTROL + } }) } const selected = findVaultOptimization(optimizations, requestedVault) if (!selected) { - return jsonWithCors( - { error: `Vault not found in optimization payload: ${requestedVault}` }, - 404, - OPTIMIZATION_GET_CORS_HEADERS - ) + return Response.json({ error: `Vault not found in optimization payload: ${requestedVault}` }, { status: 404 }) } - return jsonWithCors(selected, 200, OPTIMIZATION_GET_CORS_HEADERS, { - 'Cache-Control': CHANGE_CACHE_CONTROL + return Response.json(selected, { + headers: { + 'Cache-Control': CHANGE_CACHE_CONTROL + } }) } - return jsonWithCors(optimizations, 200, OPTIMIZATION_GET_CORS_HEADERS, { - 'Cache-Control': CHANGE_CACHE_CONTROL + return Response.json(optimizations, { + headers: { + 'Cache-Control': CHANGE_CACHE_CONTROL + } }) } catch (error) { if (isRedisAuthenticationError(error)) { - return jsonWithCors({ error: REDIS_AUTHENTICATION_ERROR_MESSAGE }, 500, OPTIMIZATION_GET_CORS_HEADERS) + return Response.json({ error: REDIS_AUTHENTICATION_ERROR_MESSAGE }, { status: 500 }) } if (isRedisConnectivityError(error)) { - return jsonWithCors({ error: REDIS_CONNECTIVITY_ERROR_MESSAGE }, 503, OPTIMIZATION_GET_CORS_HEADERS) + return Response.json({ error: REDIS_CONNECTIVITY_ERROR_MESSAGE }, { status: 503 }) } const message = error instanceof Error ? error.message : String(error) - return jsonWithCors({ error: message }, 500, OPTIMIZATION_GET_CORS_HEADERS) + return Response.json({ error: message }, { status: 500 }) } } async function handleOptimizationAlignment(req: Request): Promise { - if (req.method === 'OPTIONS') { - return new Response(null, { status: 204, headers: OPTIMIZATION_GET_CORS_HEADERS }) - } - if (req.method !== 'GET') { - return jsonWithCors({ error: 'Method not allowed' }, 405, OPTIMIZATION_GET_CORS_HEADERS) + return Response.json({ error: 'Method not allowed' }, { status: 405 }) } const url = new URL(req.url) const vault = url.searchParams.get('vault') if (!vault) { - return jsonWithCors({ error: 'vault parameter required' }, 400, OPTIMIZATION_GET_CORS_HEADERS) + return Response.json({ error: 'vault parameter required' }, { status: 400 }) } const envioUrl = process.env.ENVIO_GRAPHQL_URL if (!envioUrl) { - return jsonWithCors({ error: 'ENVIO_GRAPHQL_URL not configured' }, 503, OPTIMIZATION_GET_CORS_HEADERS) + return Response.json({ error: 'ENVIO_GRAPHQL_URL not configured' }, { status: 503 }) } try { const optimizations = await readOptimizations() if (!optimizations || optimizations.length === 0) { - return jsonWithCors({ error: 'No optimization data available' }, 404, OPTIMIZATION_GET_CORS_HEADERS) + return Response.json({ error: 'No optimization data available' }, { status: 404 }) } const optimization = findVaultOptimization(optimizations, vault) if (!optimization) { - return jsonWithCors({ error: `Vault not found: ${vault}` }, 404, OPTIMIZATION_GET_CORS_HEADERS) + return Response.json({ error: `Vault not found: ${vault}` }, { status: 404 }) } - let chainId = optimization.source.chainId - if (!chainId) { - const metadata = parseExplainMetadata(optimization.explain) - chainId = metadata.chainId - } + const metadataChainId = optimization.source.chainId ? undefined : parseExplainMetadata(optimization.explain).chainId + const chainId = optimization.source.chainId ?? metadataChainId if (!chainId) { - return jsonWithCors({ error: 'Could not determine chain ID for vault' }, 400, OPTIMIZATION_GET_CORS_HEADERS) + return Response.json({ error: 'Could not determine chain ID for vault' }, { status: 400 }) } const timestampStr = optimization.source.latestMatchedTimestampUtc ?? optimization.source.timestampUtc if (!timestampStr) { - return jsonWithCors({ error: 'No timestamp available for vault snapshot' }, 400, OPTIMIZATION_GET_CORS_HEADERS) + return Response.json({ error: 'No timestamp available for vault snapshot' }, { status: 400 }) } + const fromTs = Math.floor(new Date(timestampStr.replace(' UTC', 'Z').replace(' ', 'T')).getTime() / 1000) const numStrategies = optimization.strategyDebtRatios.length const toTs = fromTs + numStrategies * 10 * 60 * 2 - const decimals = getVaultDecimals(vault) - const events = await fetchAlignedEvents( envioUrl, vault, @@ -514,151 +741,773 @@ async function handleOptimizationAlignment(req: Request): Promise { decimals ) - return jsonWithCors(events, 200, OPTIMIZATION_GET_CORS_HEADERS, { - 'Cache-Control': ALIGNMENT_CACHE_CONTROL + return Response.json(events, { + headers: { + 'Cache-Control': ALIGNMENT_CACHE_CONTROL + } }) } catch (error) { if (isRedisAuthenticationError(error)) { - return jsonWithCors({ error: REDIS_AUTHENTICATION_ERROR_MESSAGE }, 500, OPTIMIZATION_GET_CORS_HEADERS) + return Response.json({ error: REDIS_AUTHENTICATION_ERROR_MESSAGE }, { status: 500 }) } if (isRedisConnectivityError(error)) { - return jsonWithCors({ error: REDIS_CONNECTIVITY_ERROR_MESSAGE }, 503, OPTIMIZATION_GET_CORS_HEADERS) + return Response.json({ error: REDIS_CONNECTIVITY_ERROR_MESSAGE }, { status: 503 }) } const message = error instanceof Error ? error.message : String(error) - return jsonWithCors({ error: message }, 500, OPTIMIZATION_GET_CORS_HEADERS) + return Response.json({ error: message }, { status: 500 }) } } async function handleOptimizationVaultState(req: Request): Promise { - if (req.method === 'OPTIONS') { - return new Response(null, { status: 204, headers: OPTIMIZATION_POST_CORS_HEADERS }) - } - if (req.method !== 'POST') { - return jsonWithCors({ error: 'Method not allowed' }, 405, OPTIMIZATION_POST_CORS_HEADERS) + return Response.json({ error: 'Method not allowed' }, { status: 405 }) } let body: unknown try { body = await req.json() } catch { - return jsonWithCors({ error: 'Invalid JSON body' }, 400, OPTIMIZATION_POST_CORS_HEADERS) + return Response.json({ error: 'Invalid JSON body' }, { status: 400 }) } const payload = body && typeof body === 'object' ? (body as Record) : {} const vault = typeof payload.vault === 'string' ? payload.vault : null const chainId = typeof payload.chainId === 'number' ? payload.chainId : null const strategies = Array.isArray(payload.strategies) - ? payload.strategies.filter((s: unknown): s is string => typeof s === 'string') + ? payload.strategies.filter((strategy: unknown): strategy is string => typeof strategy === 'string') : [] - if (!vault || !/^0x[a-fA-F0-9]{40}$/.test(vault)) { - return jsonWithCors({ error: 'Invalid vault address' }, 400, OPTIMIZATION_POST_CORS_HEADERS) + if (!vault || !isValidAddress(vault)) { + return Response.json({ error: 'Invalid vault address' }, { status: 400 }) } + if (chainId === null || !Number.isFinite(chainId)) { - return jsonWithCors({ error: 'Invalid chainId' }, 400, OPTIMIZATION_POST_CORS_HEADERS) + return Response.json({ error: 'Invalid chainId' }, { status: 400 }) } + if (strategies.length === 0) { - return jsonWithCors({ error: 'No strategy addresses provided' }, 400, OPTIMIZATION_POST_CORS_HEADERS) + return Response.json({ error: 'No strategy addresses provided' }, { status: 400 }) } try { const state = await fetchVaultOnChainState(chainId, vault, strategies) + const strategyDebts = Object.fromEntries( + [...state.strategyDebts].map(([strategyAddress, debt]) => [strategyAddress, debt.toString()]) + ) - const strategyDebts: Record = {} - for (const [addr, debt] of state.strategyDebts) { - strategyDebts[addr] = debt.toString() - } - - return jsonWithCors( + return Response.json( { totalAssets: state.totalAssets.toString(), strategyDebts, unallocatedBps: state.unallocatedBps }, - 200, - OPTIMIZATION_POST_CORS_HEADERS, - { 'Cache-Control': VAULT_STATE_CACHE_CONTROL } + { + headers: { + 'Cache-Control': VAULT_STATE_CACHE_CONTROL + } + } ) } catch (error) { const message = error instanceof Error ? error.message : String(error) - return jsonWithCors({ error: message }, 503, OPTIMIZATION_POST_CORS_HEADERS) + return Response.json({ error: message }, { status: 503 }) } } -serve({ - async fetch(req, server) { - const url = new URL(req.url) +async function handleHoldingsHistory(req: Request): Promise { + const url = new URL(req.url) + const address = url.searchParams.get('address') + const versionParam = url.searchParams.get('version') + const fetchType = parseHoldingsEventFetchType(url.searchParams.get('fetchType')) + const paginationMode = parseHoldingsEventPaginationMode(url.searchParams.get('paginationMode')) + const denomination = parseHoldingsHistoryDenomination(url.searchParams.get('denomination')) + const timeframe = parseHoldingsHistoryTimeframe(url.searchParams.get('timeframe')) + const vaultFilters = parseVaultFilters(url) + const debugEnabled = + isHoldingsDebugRequested(url.searchParams.get('debug')) || isHoldingsDebugRequested(process.env.HOLDINGS_DEBUG) + const debugLotsEnabled = isHoldingsDebugRequested(url.searchParams.get('debugLots')) + const debugVault = url.searchParams.get('debugVault') + const debugTx = url.searchParams.get('debugTx') + const refreshParam = url.searchParams.get('refresh') + const progressId = url.searchParams.get('progressId') + const refresh = refreshParam === 'true' || refreshParam === '1' + + if (!address) { + return Response.json({ error: 'Missing required parameter: address', status: 400 }, { status: 400 }) + } - if (url.pathname === '/api/enso/status') { - return handleEnsoStatus() - } + if (!isValidAddress(address)) { + return Response.json({ error: 'Invalid Ethereum address', status: 400 }, { status: 400 }) + } - if (url.pathname === '/api/enso/balances') { - return handleEnsoBalances(req) - } + if (vaultFilters === null) { + return Response.json({ error: 'Invalid vault filter', status: 400 }, { status: 400 }) + } - if (url.pathname === '/api/enso/route') { - return handleEnsoRoute(req) - } + const version: VaultVersion = versionParam === 'v2' || versionParam === 'v3' ? versionParam : 'all' - if (url.pathname === '/api/yvusd/aprs') { - return handleYvUsdAprs(req) - } + try { + const activeProgressId = await startHoldingsProgress({ + id: progressId, + route: 'history', + address, + message: 'Fetching historical user data' + }) + await updateHoldingsProgress(activeProgressId, { + progress: 8, + message: 'Fetching historical user data', + detail: null + }) - if (url.pathname === '/api/tenderly/status') { - return handleTenderlyStatus(req) + if (refresh) { + const cleared = await clearUserCache(address, getHoldingsTotalsCacheVersion(version)) + console.log(`[Server] Cleared ${cleared} cached entries for ${address}`) } - if (url.pathname === '/api/tenderly/snapshot') { - const accessDeniedResponse = buildTenderlyAdminAccessDeniedResponse(server.requestIP(req)?.address) - if (accessDeniedResponse) { - return accessDeniedResponse + const holdings = await withHoldingsDebugContext( + createHoldingsDebugContext('history', address, debugEnabled, { + lotsEnabled: debugLotsEnabled, + vaultFilter: debugVault, + txFilter: debugTx, + progressId: activeProgressId + }), + async () => { + debugLog('route', 'started holdings history request', { + version, + fetchType, + paginationMode, + refresh, + debugLotsEnabled, + debugVault: debugVault?.toLowerCase() ?? null, + debugTx: debugTx?.toLowerCase() ?? null + }) + + try { + const response = await getHistoricalHoldingsChart( + address, + version, + fetchType, + paginationMode, + denomination, + timeframe, + vaultFilters + ) + debugLog('route', 'completed holdings history request', { + version, + fetchType, + paginationMode, + denomination, + timeframe, + refresh, + points: response.dataPoints.length, + nonZeroPoints: response.dataPoints.filter((point) => point.value > 0).length + }) + return response + } catch (error) { + debugError('route', 'holdings history request failed', error, { version, fetchType, paginationMode }) + throw error + } } - return handleTenderlySnapshot(req) + ) + + if (!holdings.hasActivity) { + await updateHoldingsProgress(activeProgressId, { + status: 'complete', + progress: 100, + message: 'No historical holdings found', + detail: null + }) + return Response.json({ error: 'No holdings found for address', status: 404 }, { status: 404 }) } - if (url.pathname === '/api/tenderly/revert') { - const accessDeniedResponse = buildTenderlyAdminAccessDeniedResponse(server.requestIP(req)?.address) - if (accessDeniedResponse) { - return accessDeniedResponse + await updateHoldingsProgress(activeProgressId, { + status: 'complete', + progress: 100, + message: 'Historical user data ready', + detail: `${holdings.dataPoints.length} chart points` + }) + + return Response.json( + { + address: holdings.address, + version, + denomination, + timeframe, + dataPoints: holdings.dataPoints.map((dp) => ({ + date: dp.date, + value: dp.value + })) + }, + { + headers: { + 'Cache-Control': 'public, s-maxage=300, stale-while-revalidate=600' + } } - return handleTenderlyRevert(req) - } + ) + } catch (error) { + await updateHoldingsProgress(progressId, { + status: 'error', + message: 'Failed to fetch historical user data', + detail: error instanceof Error ? error.message : String(error) + }) + console.error('Error fetching holdings history:', error) + const message = error instanceof Error ? error.message : String(error) + const stack = error instanceof Error ? error.stack : undefined + return Response.json({ error: 'Failed to fetch historical holdings', message, stack, status: 502 }, { status: 502 }) + } +} + +async function handleHoldingsActivity(req: Request): Promise { + const url = new URL(req.url) + const address = url.searchParams.get('address') + const versionParam = url.searchParams.get('version') + const limit = parseHoldingsActivityLimit(url.searchParams.get('limit')) + const offset = parseHoldingsActivityOffset(url.searchParams.get('offset')) + const type = parseHoldingsActivityType(url.searchParams.get('type')) + const chainId = parseHoldingsActivityChainId(url.searchParams.get('chainId')) + const startTimestamp = parseHoldingsActivityTimestamp(url.searchParams.get('startTimestamp')) + const endTimestamp = parseHoldingsActivityTimestamp(url.searchParams.get('endTimestamp')) + const includeFacets = parseHoldingsActivityBoolean(url.searchParams.get('includeFacets')) + + if (!address) { + return Response.json({ error: 'Missing required parameter: address', status: 400 }, { status: 400 }) + } - if (url.pathname === '/api/tenderly/increase-time') { - const accessDeniedResponse = buildTenderlyAdminAccessDeniedResponse(server.requestIP(req)?.address) - if (accessDeniedResponse) { - return accessDeniedResponse + if (!isValidAddress(address)) { + return Response.json({ error: 'Invalid Ethereum address', status: 400 }, { status: 400 }) + } + + const version: VaultVersion = versionParam === 'v2' || versionParam === 'v3' ? versionParam : 'all' + + try { + const activity = await getHoldingsActivity( + address, + version, + limit, + offset, + { + type, + chainId, + startTimestamp, + endTimestamp + }, + includeFacets + ) + + return Response.json(activity, { + headers: { + 'Cache-Control': 'public, s-maxage=60, stale-while-revalidate=300' } - return handleTenderlyIncreaseTime(req) - } + }) + } catch (error) { + console.error('Error fetching holdings activity:', error) + const message = error instanceof Error ? error.message : String(error) + const stack = error instanceof Error ? error.stack : undefined + return Response.json({ error: 'Failed to fetch holdings activity', message, stack, status: 502 }, { status: 502 }) + } +} + +async function handleHoldingsActivityFacets(req: Request): Promise { + const url = new URL(req.url) + const address = url.searchParams.get('address') + const versionParam = url.searchParams.get('version') + const limitPerSource = parsePositiveIntegerParam(url.searchParams.get('limitPerSource'), 250, 1000) + const offsetPerSource = parseNonNegativeIntegerParam(url.searchParams.get('offsetPerSource')) - if (url.pathname === '/api/tenderly/fund') { - const accessDeniedResponse = buildTenderlyAdminAccessDeniedResponse(server.requestIP(req)?.address) - if (accessDeniedResponse) { - return accessDeniedResponse + if (!address) { + return Response.json({ error: 'Missing required parameter: address', status: 400 }, { status: 400 }) + } + + if (!isValidAddress(address)) { + return Response.json({ error: 'Invalid Ethereum address', status: 400 }, { status: 400 }) + } + + const version: VaultVersion = versionParam === 'v2' || versionParam === 'v3' ? versionParam : 'all' + + try { + const events = await fetchRecentAddressScopedActivityEvents( + address, + version, + limitPerSource, + undefined, + offsetPerSource + ) + const hasMore = + events.hasMoreDeposits || events.hasMoreWithdrawals || events.hasMoreTransfersIn || events.hasMoreTransfersOut + const chainIds = Array.from( + new Set( + [...events.deposits, ...events.withdrawals, ...events.transfersIn, ...events.transfersOut].map( + (event) => event.chainId + ) + ) + ).sort((firstChainId, secondChainId) => firstChainId - secondChainId) + + return Response.json( + { + address: address.toLowerCase(), + version, + facets: { chainIds }, + pageInfo: { + hasMore, + nextOffsetPerSource: hasMore ? offsetPerSource + limitPerSource : null + } + }, + { + headers: { + 'Cache-Control': 'public, s-maxage=300, stale-while-revalidate=900' + } } - return handleTenderlyFund(req) - } + ) + } catch (error) { + console.error('Error fetching holdings activity facets:', error) + const message = error instanceof Error ? error.message : String(error) + const stack = error instanceof Error ? error.stack : undefined + return Response.json( + { error: 'Failed to fetch holdings activity facets', message, stack, status: 502 }, + { status: 502 } + ) + } +} - if (url.pathname === '/api/optimization/change') { - return handleOptimizationChange(req) - } +async function handleHoldingsBreakdown(req: Request): Promise { + const url = new URL(req.url) + const address = url.searchParams.get('address') + const dateParam = url.searchParams.get('date') + const versionParam = url.searchParams.get('version') + const fetchType = parseHoldingsEventFetchType(url.searchParams.get('fetchType')) + const paginationMode = parseHoldingsEventPaginationMode(url.searchParams.get('paginationMode')) + const debugEnabled = + isHoldingsDebugRequested(url.searchParams.get('debug')) || isHoldingsDebugRequested(process.env.HOLDINGS_DEBUG) + const debugLotsEnabled = isHoldingsDebugRequested(url.searchParams.get('debugLots')) + const debugVault = url.searchParams.get('debugVault') + const debugTx = url.searchParams.get('debugTx') + + if (!address) { + return Response.json({ error: 'Missing required parameter: address', status: 400 }, { status: 400 }) + } + + if (!isValidAddress(address)) { + return Response.json({ error: 'Invalid Ethereum address', status: 400 }, { status: 400 }) + } + + const breakdownTimestamp = parseUtcDateParam(dateParam) + if (dateParam && breakdownTimestamp === null) { + return Response.json({ error: 'Invalid date format, expected YYYY-MM-DD', status: 400 }, { status: 400 }) + } + + const version: VaultVersion = versionParam === 'v2' || versionParam === 'v3' ? versionParam : 'all' + + try { + const breakdown = await withHoldingsDebugContext( + createHoldingsDebugContext('breakdown', address, debugEnabled, { + lotsEnabled: debugLotsEnabled, + vaultFilter: debugVault, + txFilter: debugTx + }), + async () => { + debugLog('route', 'started holdings breakdown request', { + version, + date: dateParam, + fetchType, + paginationMode, + debugLotsEnabled, + debugVault: debugVault?.toLowerCase() ?? null, + debugTx: debugTx?.toLowerCase() ?? null + }) + + try { + const response = await getHoldingsBreakdown( + address, + version, + fetchType, + paginationMode, + breakdownTimestamp ?? undefined + ) + debugLog('route', 'completed holdings breakdown request', { + version, + date: response.date, + fetchType, + paginationMode, + timestamp: response.timestamp, + totalVaults: response.summary.totalVaults, + vaultsWithShares: response.summary.vaultsWithShares, + totalUsdValue: response.summary.totalUsdValue + }) + return response + } catch (error) { + debugError('route', 'holdings breakdown request failed', error, { + version, + date: dateParam, + fetchType, + paginationMode + }) + throw error + } + } + ) - if (url.pathname === '/api/optimization/alignment') { - return handleOptimizationAlignment(req) + return Response.json(breakdown, { + headers: { + 'Cache-Control': 'public, s-maxage=300, stale-while-revalidate=600' + } + }) + } catch (error) { + console.error('Error fetching holdings breakdown:', error) + const message = error instanceof Error ? error.message : String(error) + const stack = error instanceof Error ? error.stack : undefined + return Response.json({ error: 'Failed to fetch holdings breakdown', message, stack, status: 502 }, { status: 502 }) + } +} + +async function handleHoldingsProtocolReturnHistory(req: Request): Promise { + const url = new URL(req.url) + const address = url.searchParams.get('address') + const versionParam = url.searchParams.get('version') + const vaultFilters = parseVaultFilters(url) + const timeframe = parseHoldingsHistoryTimeframe(url.searchParams.get('timeframe')) + const debugEnabled = + isHoldingsDebugRequested(url.searchParams.get('debug')) || isHoldingsDebugRequested(process.env.HOLDINGS_DEBUG) + const debugLotsEnabled = isHoldingsDebugRequested(url.searchParams.get('debugLots')) + const debugVault = url.searchParams.get('debugVault') + const debugTx = url.searchParams.get('debugTx') + const fetchType = parseHoldingsEventFetchType(url.searchParams.get('fetchType')) + const paginationMode = parseHoldingsEventPaginationMode(url.searchParams.get('paginationMode')) + const progressId = url.searchParams.get('progressId') + + if (!address) { + return Response.json({ error: 'Missing required parameter: address', status: 400 }, { status: 400 }) + } + + if (!isValidAddress(address)) { + return Response.json({ error: 'Invalid Ethereum address', status: 400 }, { status: 400 }) + } + + if (vaultFilters === null) { + return Response.json({ error: 'Invalid vault filter', status: 400 }, { status: 400 }) + } + + const version: VaultVersion = versionParam === 'v2' || versionParam === 'v3' ? versionParam : 'all' + + try { + const activeProgressId = await startHoldingsProgress({ + id: progressId, + route: 'pnl-simple-history', + address, + message: 'Fetching historical user data' + }) + await updateHoldingsProgress(activeProgressId, { + progress: 8, + message: 'Fetching historical user data', + detail: null + }) + + const history = await withHoldingsDebugContext( + createHoldingsDebugContext('protocol-return-history', address, debugEnabled, { + lotsEnabled: debugLotsEnabled, + vaultFilter: debugVault, + txFilter: debugTx, + progressId: activeProgressId + }), + async () => { + debugLog('route', 'started holdings protocol return history request', { + version, + timeframe, + vaults: vaultFilters?.map((vault) => `${vault.chainId}:${vault.vaultAddress.toLowerCase()}`) ?? null, + fetchType, + paginationMode, + debugLotsEnabled, + debugVault: debugVault?.toLowerCase() ?? null, + debugTx: debugTx?.toLowerCase() ?? null + }) + + try { + const response = await getHoldingsProtocolReturnHistory( + address, + version, + fetchType, + paginationMode, + timeframe, + vaultFilters + ) + debugLog('route', 'completed holdings protocol return history request', { + version, + timeframe, + vaults: vaultFilters?.map((vault) => `${vault.chainId}:${vault.vaultAddress.toLowerCase()}`) ?? null, + fetchType, + paginationMode, + totalVaults: response.summary.totalVaults, + points: response.dataPoints.length + }) + return response + } catch (error) { + debugError('route', 'holdings protocol return history request failed', error, { + version, + timeframe, + vaults: vaultFilters?.map((vault) => `${vault.chainId}:${vault.vaultAddress.toLowerCase()}`) ?? null, + fetchType, + paginationMode + }) + throw error + } + } + ) + + if (history.summary.totalVaults === 0) { + await updateHoldingsProgress(activeProgressId, { + status: 'complete', + progress: 100, + message: 'No historical holdings found', + detail: null + }) + return Response.json({ error: 'No holdings found for address', status: 404 }, { status: 404 }) } - if (url.pathname === '/api/optimization/vault-state') { - return handleOptimizationVaultState(req) + await updateHoldingsProgress(activeProgressId, { + status: 'complete', + progress: 100, + message: 'Historical user data ready', + detail: `${history.dataPoints.length} chart points` + }) + + return Response.json(history, { + headers: { + 'Cache-Control': 'public, s-maxage=300, stale-while-revalidate=600' + } + }) + } catch (error) { + await updateHoldingsProgress(progressId, { + status: 'error', + message: 'Failed to fetch historical user data', + detail: error instanceof Error ? error.message : String(error) + }) + console.error('Error fetching holdings protocol return history:', error) + const message = error instanceof Error ? error.message : String(error) + const stack = error instanceof Error ? error.stack : undefined + return Response.json( + { error: 'Failed to fetch holdings protocol return history', message, stack, status: 502 }, + { status: 502 } + ) + } +} + +async function handleInvalidateCache(req: Request): Promise { + if (req.method !== 'POST') { + return Response.json({ error: 'Method not allowed' }, { status: 405 }) + } + + const adminSecret = process.env.ADMIN_SECRET + if (!adminSecret) { + return Response.json({ error: 'Admin endpoint not configured' }, { status: 503 }) + } + + const providedSecret = req.headers.get('x-admin-secret') + if (providedSecret !== adminSecret) { + return Response.json({ error: 'Unauthorized' }, { status: 401 }) + } + + if (!isHoldingsStorageEnabled()) { + return Response.json( + { error: 'Caching not enabled (UPSTASH_REDIS_REST_URL_PORTFOLIO/TOKEN_PORTFOLIO not configured)' }, + { status: 503 } + ) + } + + let body: unknown + try { + body = await req.json() + } catch (_error) { + return Response.json( + { + error: 'Invalid request body', + expected: { vaults: [{ address: '0x...', chainId: 1 }] } + }, + { status: 400 } + ) + } + + if (!validateInvalidateBody(body)) { + return Response.json( + { + error: 'Invalid request body', + expected: { vaults: [{ address: '0x...', chainId: 1 }] } + }, + { status: 400 } + ) + } + + try { + await initializeHoldingsStorage() + if (!isHoldingsStorageEnabled()) { + return Response.json( + { error: 'Caching not enabled (UPSTASH_REDIS_REST_URL_PORTFOLIO/TOKEN_PORTFOLIO not configured)' }, + { status: 503 } + ) } - return new Response('Not found', { status: 404 }) - }, - port: API_SERVER_PORT -}) + const vaults: VaultIdentifier[] = body.vaults.map((vault) => ({ + address: vault.address, + chainId: vault.chainId + })) -console.log(`🚀 API server running on http://localhost:${API_SERVER_PORT}`) + const invalidatedCount = await invalidateVaults(vaults) + + return Response.json({ + success: true, + invalidated: invalidatedCount, + vaults: vaults.map((vault) => `${vault.chainId}:${vault.address.toLowerCase()}`), + timestamp: new Date().toISOString() + }) + } catch (error) { + console.error('[Admin] Invalidate cache error:', error) + return Response.json({ error: 'Failed to invalidate cache' }, { status: 500 }) + } +} + +async function main() { + process.on('uncaughtException', (error) => { + console.error('💥 Uncaught Exception:', error) + }) + + process.on('unhandledRejection', (reason, promise) => { + console.error('💥 Unhandled Rejection at:', promise, 'reason:', reason) + }) + + validateConfig() + + await initializeHoldingsStorage() + + serve({ + async fetch(req, server) { + const url = new URL(req.url) + console.log(`[Server] ${req.method} ${url.pathname}`) + + try { + if (req.method === 'OPTIONS') { + return handleCorsPreFlight() + } + + if (url.pathname === '/api/enso/status') { + return withCors(handleEnsoStatus()) + } + + if (url.pathname === '/api/enso/balances') { + return withCors(await handleEnsoBalances(req)) + } + + if (url.pathname === '/api/enso/route') { + return withCors(await handleEnsoRoute(req)) + } + + if (url.pathname === '/api/holdings/history') { + return withCors(await handleHoldingsHistory(req)) + } + + if (url.pathname === '/api/holdings/progress') { + return withCors(await handleHoldingsProgress(req)) + } + + if (url.pathname === '/api/holdings/activity') { + return withCors(await handleHoldingsActivity(req)) + } + + if (url.pathname === '/api/holdings/activity-facets') { + return withCors(await handleHoldingsActivityFacets(req)) + } + + if (url.pathname === '/api/holdings/breakdown') { + return withCors(await handleHoldingsBreakdown(req)) + } + + if ( + url.pathname === '/api/holdings/protocol-return/history' || + url.pathname === '/api/holdings/pnl/simple-history' + ) { + return withCors(await handleHoldingsProtocolReturnHistory(req)) + } + + if (url.pathname === '/api/admin/invalidate-cache') { + return withCors(await handleInvalidateCache(req)) + } + + if (url.pathname === '/api/yvusd/aprs') { + return withCors(await handleYvUsdAprs(req)) + } + + if (url.pathname === '/api/optimization/change') { + return withCors(await handleOptimizationChange(req)) + } + + if (url.pathname === '/api/optimization/alignment') { + return withCors(await handleOptimizationAlignment(req)) + } + + if (url.pathname === '/api/optimization/vault-state') { + return withCors(await handleOptimizationVaultState(req)) + } + + if (url.pathname === '/api/tenderly/status') { + return withCors(handleTenderlyStatus(req)) + } + + if (url.pathname === '/api/tenderly/snapshot') { + const accessDeniedResponse = buildTenderlyAdminAccessDeniedResponse(server.requestIP(req)?.address) + if (accessDeniedResponse) { + return withCors(accessDeniedResponse) + } + return withCors(await handleTenderlySnapshot(req)) + } + + if (url.pathname === '/api/tenderly/revert') { + const accessDeniedResponse = buildTenderlyAdminAccessDeniedResponse(server.requestIP(req)?.address) + if (accessDeniedResponse) { + return withCors(accessDeniedResponse) + } + return withCors(await handleTenderlyRevert(req)) + } + + if (url.pathname === '/api/tenderly/increase-time') { + const accessDeniedResponse = buildTenderlyAdminAccessDeniedResponse(server.requestIP(req)?.address) + if (accessDeniedResponse) { + return withCors(accessDeniedResponse) + } + return withCors(await handleTenderlyIncreaseTime(req)) + } + + if (url.pathname === '/api/tenderly/fund') { + const accessDeniedResponse = buildTenderlyAdminAccessDeniedResponse(server.requestIP(req)?.address) + if (accessDeniedResponse) { + return withCors(accessDeniedResponse) + } + return withCors(await handleTenderlyFund(req)) + } + + return withCors(new Response('Not found', { status: 404 })) + } catch (error) { + console.error('💥 Request handler error:', error) + return withCors( + Response.json( + { error: 'Internal server error', message: error instanceof Error ? error.message : String(error) }, + { status: 500 } + ) + ) + } + }, + port: API_PORT, + idleTimeout: 120 + }) + + console.log(`🚀 API server running on http://localhost:${API_PORT}`) + console.log(`📊 Holdings API: http://localhost:${API_PORT}/api/holdings/history?address=0x...`) + console.log(`🗂️ Holdings Activity API: http://localhost:${API_PORT}/api/holdings/activity?address=0x...`) + console.log(`🧩 Holdings Breakdown API: http://localhost:${API_PORT}/api/holdings/breakdown?address=0x...`) + console.log(`💹 PnL API: http://localhost:${API_PORT}/api/holdings/pnl?address=0x...`) + console.log(`📈 Simple PnL API: http://localhost:${API_PORT}/api/holdings/pnl/simple?address=0x...`) + console.log(`📊 Simple PnL History API: http://localhost:${API_PORT}/api/holdings/pnl/simple-history?address=0x...`) + console.log(`🧾 PnL Drilldown API: http://localhost:${API_PORT}/api/holdings/pnl/drilldown?address=0x...`) +} + +main().catch((error) => { + console.error('Failed to start server:', error) + process.exit(1) +}) diff --git a/bun.lock b/bun.lock index 737f10329..f6a15a269 100644 --- a/bun.lock +++ b/bun.lock @@ -1,23 +1,22 @@ { "lockfileVersion": 1, - "configVersion": 0, "workspaces": { "": { "name": "yearnfi", "dependencies": { - "@headlessui/react": "2.2.9", - "@plausible-analytics/tracker": "0.4.4", - "@rainbow-me/rainbowkit": "2.2.10", + "@headlessui/react": "2.2.10", + "@plausible-analytics/tracker": "0.4.5", + "@rainbow-me/rainbowkit": "2.2.11", "@react-hookz/web": "24.0.4", "@tanstack/react-query": "5.90.21", - "@tanstack/react-virtual": "3.13.18", + "@tanstack/react-virtual": "3.13.24", "@upstash/redis": "1.37.0", "cross-fetch": "4.1.0", "ethers": "5.7.2", "framer-motion": "12.34.0", "graphql": "16.12.0", - "react": "19.2.4", - "react-dom": "19.2.4", + "react": "19.2.6", + "react-dom": "19.2.6", "react-hot-toast": "2.6.0", "react-markdown": "10.1.0", "react-rewards": "2.1.0", @@ -30,7 +29,7 @@ "zod": "4.3.6", }, "devDependencies": { - "@biomejs/biome": "2.4.0", + "@biomejs/biome": "2.4.14", "@tailwindcss/postcss": "4.1.18", "@testing-library/react": "16.3.2", "@types/minimatch": "6.0.0", @@ -43,7 +42,7 @@ "concurrently": "9.2.1", "husky": "9.1.7", "lint-staged": "16.2.7", - "postcss": "8.5.6", + "postcss": "8.5.14", "tailwindcss": "4.1.18", "typescript": "5.9.3", "vite": "7.3.2", @@ -58,7 +57,7 @@ "@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], - "@babel/compat-data": ["@babel/compat-data@7.29.0", "", {}, "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg=="], + "@babel/compat-data": ["@babel/compat-data@7.29.3", "", {}, "sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg=="], "@babel/core": ["@babel/core@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA=="], @@ -80,15 +79,15 @@ "@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="], - "@babel/helpers": ["@babel/helpers@7.28.6", "", { "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw=="], + "@babel/helpers": ["@babel/helpers@7.29.2", "", { "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.29.0" } }, "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw=="], - "@babel/parser": ["@babel/parser@7.29.0", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww=="], + "@babel/parser": ["@babel/parser@7.29.3", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA=="], "@babel/plugin-transform-react-jsx-self": ["@babel/plugin-transform-react-jsx-self@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw=="], "@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw=="], - "@babel/runtime": ["@babel/runtime@7.28.6", "", {}, "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA=="], + "@babel/runtime": ["@babel/runtime@7.29.2", "", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="], "@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="], @@ -98,29 +97,29 @@ "@base-org/account": ["@base-org/account@1.1.1", "", { "dependencies": { "@noble/hashes": "1.4.0", "clsx": "1.2.1", "eventemitter3": "5.0.1", "idb-keyval": "6.2.1", "ox": "0.6.9", "preact": "10.24.2", "viem": "^2.31.7", "zustand": "5.0.3" } }, "sha512-IfVJPrDPhHfqXRDb89472hXkpvJuQQR7FDI9isLPHEqSYt/45whIoBxSPgZ0ssTt379VhQo4+87PWI1DoLSfAQ=="], - "@biomejs/biome": ["@biomejs/biome@2.4.0", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.4.0", "@biomejs/cli-darwin-x64": "2.4.0", "@biomejs/cli-linux-arm64": "2.4.0", "@biomejs/cli-linux-arm64-musl": "2.4.0", "@biomejs/cli-linux-x64": "2.4.0", "@biomejs/cli-linux-x64-musl": "2.4.0", "@biomejs/cli-win32-arm64": "2.4.0", "@biomejs/cli-win32-x64": "2.4.0" }, "bin": { "biome": "bin/biome" } }, "sha512-iluT61cORUDIC5i/y42ljyQraCemmmcgbMLLCnYO+yh+2hjTmcMFcwY8G0zTzWCsPb3t3AyKc+0t/VuhPZULUg=="], + "@biomejs/biome": ["@biomejs/biome@2.4.14", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.4.14", "@biomejs/cli-darwin-x64": "2.4.14", "@biomejs/cli-linux-arm64": "2.4.14", "@biomejs/cli-linux-arm64-musl": "2.4.14", "@biomejs/cli-linux-x64": "2.4.14", "@biomejs/cli-linux-x64-musl": "2.4.14", "@biomejs/cli-win32-arm64": "2.4.14", "@biomejs/cli-win32-x64": "2.4.14" }, "bin": { "biome": "bin/biome" } }, "sha512-TmAvxOEgrpLypzVGJ8FulIZnlyA9TxrO1hyqYrCz9r+bwma9xXxuLA5IuYnj55XQneFx460KjRbx6SWGLkg3bQ=="], - "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.4.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-L+YpOtPSuU0etomfvFTPWRsa7+8ejaJL3yaROEoT/96HDJbR6OsvZQk0C8JUYou+XFdP+JcGxqZknkp4n934RA=="], + "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.4.14", "", { "os": "darwin", "cpu": "arm64" }, "sha512-XvgoE9XOawUOQPdmvs4J7wPhi/DLwSCGks3AlPJDmh34O0awRTqCED1HRcRDdpf1Zrp4us4MGOOdIxNpbqNF5Q=="], - "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.4.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-Aq+S7ffpb5ynTyLgtnEjG+W6xuTd2F7FdC7J6ShpvRhZwJhjzwITGF9vrqoOnw0sv1XWkt2Q1Rpg+hleg/Xg7Q=="], + "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.4.14", "", { "os": "darwin", "cpu": "x64" }, "sha512-jE7hKBCFhOx3uUh+ZkWBfOHxAcILPfhFplNkuID/eZeSTLHzfZzoZxW8fbqY9xXRnPi7jGNAf1iPVR+0yWsM/Q=="], - "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.4.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-u2p54IhvNAWB+h7+rxCZe3reNfQYFK+ppDw+q0yegrGclFYnDPZAntv/PqgUacpC3uxTeuWFgWW7RFe3lHuxOA=="], + "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.4.14", "", { "os": "linux", "cpu": "arm64" }, "sha512-2TELhZnW5RSLL063l9rc5xLpA0ZIw0Ccwy/0q384rvNAgFw3yI76bd59547yxowdQr5MNPET/xDLrLuvgSeeWQ=="], - "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.4.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-1rhDUq8sf7xX3tg7vbnU3WVfanKCKi40OXc4VleBMzRStmQHdeBY46aFP6VdwEomcVjyNiu+Zcr3LZtAdrZrjQ=="], + "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.4.14", "", { "os": "linux", "cpu": "arm64" }, "sha512-/z+6gqAqqUQTHazwStxSXKHg9b8UvqBmDFRp+c4wYbq2KXhELQDon9EoC9RpmQ8JWkqQx/lIUy/cs+MhzDZp6A=="], - "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.4.0", "", { "os": "linux", "cpu": "x64" }, "sha512-WVFOhsnzhrbMGOSIcB9yFdRV2oG2KkRRhIZiunI9gJqSU3ax9ErdnTxRfJUxZUI9NbzVxC60OCXNcu+mXfF/Tw=="], + "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.4.14", "", { "os": "linux", "cpu": "x64" }, "sha512-zHrlQZDBDUz4OLAraYpWKcnLS6HOewBFWYOzY91d1ZjdqZwibOyb6BEu6WuWLugyo0P3riCmsbV9UqV1cSXwQg=="], - "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.4.0", "", { "os": "linux", "cpu": "x64" }, "sha512-Omo0xhl63z47X+CrE5viEWKJhejJyndl577VoXg763U/aoATrK3r5+8DPh02GokWPeODX1Hek00OtjjooGan9w=="], + "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.4.14", "", { "os": "linux", "cpu": "x64" }, "sha512-R6BWgJdQOwW9ulJatuTVrQkjnODjqHZkKNOqb1sz++3Noe5LYd0i3PchnOBUCYAPHoPWHhjJqbdZlHEu0hpjdA=="], - "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.4.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-aqRwW0LJLV1v1NzyLvLWQhdLmDSAV1vUh+OBdYJaa8f28XBn5BZavo+WTfqgEzALZxlNfBmu6NGO6Al3MbCULw=="], + "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.4.14", "", { "os": "win32", "cpu": "arm64" }, "sha512-M3EH5hqOI/F/FUA2u4xcLoUgmxd218mvuj/6JL7Hv2toQvr2/AdOvKSpGkoRuWFCtQPVa+ZqkEV3Q5xBA9+XSA=="], - "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.0", "", { "os": "win32", "cpu": "x64" }, "sha512-g47s+V+OqsGxbSZN3lpav6WYOk0PIc3aCBAq+p6dwSynL3K5MA6Cg6nkzDOlu28GEHwbakW+BllzHCJCxnfK5Q=="], + "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.14", "", { "os": "win32", "cpu": "x64" }, "sha512-WL0EG5qE+EAKomGXbf2g6VnSKJhTL3tXC0QRzWRwA5VpjxNYa6H4P7ZWfymbGE4IhZZQi1KXQ2R0YjwInmz2fA=="], "@bytecodealliance/preview2-shim": ["@bytecodealliance/preview2-shim@0.17.6", "", {}, "sha512-n3cM88gTen5980UOBAD6xDcNNL3ocTK8keab21bpx1ONdA+ARj7uD1qoFxOWCyKlkpSi195FH+GeAut7Oc6zZw=="], "@coinbase/wallet-sdk": ["@coinbase/wallet-sdk@4.3.6", "", { "dependencies": { "@noble/hashes": "1.4.0", "clsx": "1.2.1", "eventemitter3": "5.0.1", "idb-keyval": "6.2.1", "ox": "0.6.9", "preact": "10.24.2", "viem": "^2.27.2", "zustand": "5.0.3" } }, "sha512-4q8BNG1ViL4mSAAvPAtpwlOs1gpC+67eQtgIwNvT3xyeyFFd+guwkc8bcX5rTmQhXpqnhzC4f0obACbP9CqMSA=="], - "@ecies/ciphers": ["@ecies/ciphers@0.2.5", "", { "peerDependencies": { "@noble/ciphers": "^1.0.0" } }, "sha512-GalEZH4JgOMHYYcYmVqnFirFsjZHeoGMDt9IxEnM9F7GRUUyUksJ7Ou53L83WHJq3RWKD3AcBpo0iQh0oMpf8A=="], + "@ecies/ciphers": ["@ecies/ciphers@0.2.6", "", { "peerDependencies": { "@noble/ciphers": "^1.0.0" } }, "sha512-patgsRPKGkhhoBjETV4XxD0En4ui5fbX0hzayqI3M8tvNMGUoUvmyYAIWwlxBc1KX5cturfqByYdj5bYGRpN9g=="], "@edge-runtime/format": ["@edge-runtime/format@2.2.1", "", {}, "sha512-JQTRVuiusQLNNLe2W9tnzBlV/GvSVcozLl4XZHk5swnRZ/v6jp8TqR8P7sqmJsQqblDZ3EztcWmLDbhRje/+8g=="], @@ -256,19 +255,25 @@ "@fastify/busboy": ["@fastify/busboy@2.1.1", "", {}, "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA=="], - "@floating-ui/core": ["@floating-ui/core@1.7.4", "", { "dependencies": { "@floating-ui/utils": "^0.2.10" } }, "sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg=="], + "@floating-ui/core": ["@floating-ui/core@1.7.5", "", { "dependencies": { "@floating-ui/utils": "^0.2.11" } }, "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ=="], - "@floating-ui/dom": ["@floating-ui/dom@1.7.5", "", { "dependencies": { "@floating-ui/core": "^1.7.4", "@floating-ui/utils": "^0.2.10" } }, "sha512-N0bD2kIPInNHUHehXhMke1rBGs1dwqvC9O9KYMyyjK7iXt7GAhnro7UlcuYcGdS/yYOlq0MAVgrow8IbWJwyqg=="], + "@floating-ui/dom": ["@floating-ui/dom@1.7.6", "", { "dependencies": { "@floating-ui/core": "^1.7.5", "@floating-ui/utils": "^0.2.11" } }, "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ=="], "@floating-ui/react": ["@floating-ui/react@0.26.28", "", { "dependencies": { "@floating-ui/react-dom": "^2.1.2", "@floating-ui/utils": "^0.2.8", "tabbable": "^6.0.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-yORQuuAtVpiRjpMhdc0wJj06b9JFjrYF4qp96j++v2NBpbi6SEGF7donUJ3TMieerQ6qVkAv1tgr7L4r5roTqw=="], - "@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.7", "", { "dependencies": { "@floating-ui/dom": "^1.7.5" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-0tLRojf/1Go2JgEVm+3Frg9A3IW8bJgKgdO0BN5RkF//ufuz2joZM63Npau2ff3J6lUVYgDSNzNkR+aH3IVfjg=="], + "@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.8", "", { "dependencies": { "@floating-ui/dom": "^1.7.6" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A=="], - "@floating-ui/utils": ["@floating-ui/utils@0.2.10", "", {}, "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="], + "@floating-ui/utils": ["@floating-ui/utils@0.2.11", "", {}, "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="], "@gemini-wallet/core": ["@gemini-wallet/core@0.2.0", "", { "dependencies": { "@metamask/rpc-errors": "7.0.2", "eventemitter3": "5.0.1" }, "peerDependencies": { "viem": ">=2.0.0" } }, "sha512-vv9aozWnKrrPWQ3vIFcWk7yta4hQW1Ie0fsNNPeXnjAxkbXr2hqMagEptLuMxpEP2W3mnRu05VDNKzcvAuuZDw=="], - "@headlessui/react": ["@headlessui/react@2.2.9", "", { "dependencies": { "@floating-ui/react": "^0.26.16", "@react-aria/focus": "^3.20.2", "@react-aria/interactions": "^3.25.0", "@tanstack/react-virtual": "^3.13.9", "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc", "react-dom": "^18 || ^19 || ^19.0.0-rc" } }, "sha512-Mb+Un58gwBn0/yWZfyrCh0TJyurtT+dETj7YHleylHk5od3dv2XqETPGWMyQ5/7sYN7oWdyM1u9MvC0OC8UmzQ=="], + "@headlessui/react": ["@headlessui/react@2.2.10", "", { "dependencies": { "@floating-ui/react": "^0.26.16", "@react-aria/focus": "^3.20.2", "@react-aria/interactions": "^3.25.0", "@tanstack/react-virtual": "^3.13.9", "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc", "react-dom": "^18 || ^19 || ^19.0.0-rc" } }, "sha512-5pVLNK9wlpxTUTy9GpgbX/SdcRh+HBnPktjM2wbiLTH4p+2EPHBO1aoSryUCuKUIItdDWO9ITlhUL8UnUN/oIA=="], + + "@internationalized/date": ["@internationalized/date@3.12.1", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-6IedsVWXyq4P9Tj+TxuU8WGWM70hYLl12nbYU8jkikVpa6WXapFazPUcHUMDMoWftIDE2ILDkFFte6W2nFCkRQ=="], + + "@internationalized/number": ["@internationalized/number@3.6.6", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-iFgmQaXHE0vytNfpLZWOC2mEJCBRzcUxt53Xf/yCXG93lRvqas237i3r7X4RKMwO3txiyZD4mQjKAByFv6UGSQ=="], + + "@internationalized/string": ["@internationalized/string@3.2.8", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-NdbMQUSfXLYIQol5VyMtinm9pZDciiMfN7RtmSuSB78io1hqwJ0naYfxyW6vgxWBkzWymQa/3uLDlbfmshtCaA=="], "@isaacs/balanced-match": ["@isaacs/balanced-match@4.0.1", "", {}, "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ=="], @@ -288,7 +293,7 @@ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], - "@lit-labs/ssr-dom-shim": ["@lit-labs/ssr-dom-shim@1.5.1", "", {}, "sha512-Aou5UdlSpr5whQe8AA/bZG0jMj96CoJIWbGfZ91qieWu5AWUMKw8VR/pAkQkJYvBNhmCcWnZlyyk5oze8JIqYA=="], + "@lit-labs/ssr-dom-shim": ["@lit-labs/ssr-dom-shim@1.6.0", "", {}, "sha512-VHb0ALPMTlgKjM6yIxxoQNnpKyUKLD04VzeQdsiXkMqkvYlAHxq9glGLmgbb889/1GsohSOAjvQYoiBppXFqrQ=="], "@lit/reactive-element": ["@lit/reactive-element@2.1.2", "", { "dependencies": { "@lit-labs/ssr-dom-shim": "^1.5.0" } }, "sha512-pbCDiVMnne1lYUIaYNN5wrwQXDtHaYtg7YEFPeW+hws6U47WeFvISGUWekPGKWOP1ygrs0ef0o1VJMk1exos5A=="], @@ -320,7 +325,7 @@ "@metamask/superstruct": ["@metamask/superstruct@3.2.1", "", {}, "sha512-fLgJnDOXFmuVlB38rUN5SmU7hAFQcCjrg3Vrxz67KTY7YHFnSNEKvX4avmEBdOI0yTCxZjwMCFEqsC8k2+Wd3g=="], - "@metamask/utils": ["@metamask/utils@11.10.0", "", { "dependencies": { "@ethereumjs/tx": "^4.2.0", "@metamask/superstruct": "^3.1.0", "@noble/hashes": "^1.3.1", "@scure/base": "^1.1.3", "@types/debug": "^4.1.7", "@types/lodash": "^4.17.20", "debug": "^4.3.4", "lodash": "^4.17.21", "pony-cause": "^2.1.10", "semver": "^7.5.4", "uuid": "^9.0.1" } }, "sha512-+bWmTOANx1MbBW6RFM8Se4ZoigFYGXiuIrkhjj4XnG5Aez8uWaTSZ76yn9srKKClv+PoEVoAuVtcUOogFEMUNA=="], + "@metamask/utils": ["@metamask/utils@11.11.0", "", { "dependencies": { "@ethereumjs/tx": "^4.2.0", "@metamask/superstruct": "^3.1.0", "@noble/hashes": "^1.3.1", "@scure/base": "^1.1.3", "@types/debug": "^4.1.7", "@types/lodash": "^4.17.20", "debug": "^4.3.4", "lodash": "^4.17.21", "pony-cause": "^2.1.10", "semver": "^7.5.4", "uuid": "^9.0.1" } }, "sha512-0nF2CWjWQr/m0Y2t2lJnBTU1/CZPPTvKvcESLplyWe/tyeb8zFOi/FeneDmaFnML6LYRIGZU6f+xR0jKAIUZfw=="], "@noble/ciphers": ["@noble/ciphers@1.3.0", "", {}, "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw=="], @@ -336,7 +341,7 @@ "@paulmillr/qr": ["@paulmillr/qr@0.2.1", "", {}, "sha512-IHnV6A+zxU7XwmKFinmYjUcwlyK9+xkG3/s9KcQhI9BjQKycrJ1JRO+FbNYPwZiPKW3je/DR0k7w8/gLa5eaxQ=="], - "@plausible-analytics/tracker": ["@plausible-analytics/tracker@0.4.4", "", {}, "sha512-fz0NOYUEYXtg1TBaPEEvtcBq3FfmLFuTe1VZw4M8sTWX129br5dguu3M15+plOQnc181ShYe67RfwhKgK89VnA=="], + "@plausible-analytics/tracker": ["@plausible-analytics/tracker@0.4.5", "", {}, "sha512-6BfAGejXY+YA3Cw6LYT2Zpn4hTxDtPQAawFsYUsQCOg78wIS5C4deAGXTfJffa5VleMWITv5lpJ/EYuQBl1tPA=="], "@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="], @@ -372,25 +377,17 @@ "@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="], - "@rainbow-me/rainbowkit": ["@rainbow-me/rainbowkit@2.2.10", "", { "dependencies": { "@vanilla-extract/css": "1.17.3", "@vanilla-extract/dynamic": "2.1.4", "@vanilla-extract/sprinkles": "1.6.4", "clsx": "2.1.1", "cuer": "0.0.3", "react-remove-scroll": "2.6.2", "ua-parser-js": "^1.0.37" }, "peerDependencies": { "@tanstack/react-query": ">=5.0.0", "react": ">=18", "react-dom": ">=18", "viem": "2.x", "wagmi": "^2.9.0" } }, "sha512-8+E4die1A2ovN9t3lWxWnwqTGEdFqThXDQRj+E4eDKuUKyymYD+66Gzm6S9yfg8E95c6hmGlavGUfYPtl1EagA=="], - - "@react-aria/focus": ["@react-aria/focus@3.21.4", "", { "dependencies": { "@react-aria/interactions": "^3.27.0", "@react-aria/utils": "^3.33.0", "@react-types/shared": "^3.33.0", "@swc/helpers": "^0.5.0", "clsx": "^2.0.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-6gz+j9ip0/vFRTKJMl3R30MHopn4i19HqqLfSQfElxJD+r9hBnYG1Q6Wd/kl/WRR1+CALn2F+rn06jUnf5sT8Q=="], + "@rainbow-me/rainbowkit": ["@rainbow-me/rainbowkit@2.2.11", "", { "dependencies": { "@vanilla-extract/css": "1.20.1", "@vanilla-extract/dynamic": "2.1.5", "@vanilla-extract/sprinkles": "1.6.5", "clsx": "2.1.1", "cuer": "0.0.3", "react-remove-scroll": "2.7.2", "ua-parser-js": "^2.0.9" }, "peerDependencies": { "@tanstack/react-query": ">=5.0.0", "react": ">=18", "react-dom": ">=18", "viem": "2.x", "wagmi": "^2.9.0" } }, "sha512-FHPsRHMBpuHHhuyKktAR13O9agmsUUunDnVEP4hG1dSZ2JojXLUSWyLG28VbGIJakHYylkNguiLFnqM/BM8ERA=="], - "@react-aria/interactions": ["@react-aria/interactions@3.27.0", "", { "dependencies": { "@react-aria/ssr": "^3.9.10", "@react-aria/utils": "^3.33.0", "@react-stately/flags": "^3.1.2", "@react-types/shared": "^3.33.0", "@swc/helpers": "^0.5.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-D27pOy+0jIfHK60BB26AgqjjRFOYdvVSkwC31b2LicIzRCSPOSP06V4gMHuGmkhNTF4+YWDi1HHYjxIvMeiSlA=="], + "@react-aria/focus": ["@react-aria/focus@3.22.0", "", { "dependencies": { "@swc/helpers": "^0.5.0", "react-aria": "3.48.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-ZfDOVuVhqDsM9mkNji3QUZ/d40JhlVgXrDkrfXylM1035QCrcTHN7m2DpbE95sU2A8EQb4wikvt5jM6K/73BPg=="], - "@react-aria/ssr": ["@react-aria/ssr@3.9.10", "", { "dependencies": { "@swc/helpers": "^0.5.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-hvTm77Pf+pMBhuBm760Li0BVIO38jv1IBws1xFm1NoL26PU+fe+FMW5+VZWyANR6nYL65joaJKZqOdTQMkO9IQ=="], - - "@react-aria/utils": ["@react-aria/utils@3.33.0", "", { "dependencies": { "@react-aria/ssr": "^3.9.10", "@react-stately/flags": "^3.1.2", "@react-stately/utils": "^3.11.0", "@react-types/shared": "^3.33.0", "@swc/helpers": "^0.5.0", "clsx": "^2.0.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-yvz7CMH8d2VjwbSa5nGXqjU031tYhD8ddax95VzJsHSPyqHDEGfxul8RkhGV6oO7bVqZxVs6xY66NIgae+FHjw=="], + "@react-aria/interactions": ["@react-aria/interactions@3.28.0", "", { "dependencies": { "@react-types/shared": "^3.34.0", "@swc/helpers": "^0.5.0", "react-aria": "3.48.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-OXwdU1EWFdMxmr/K1CXNGJzmNlCClByb+PuCaqUyzBymHPCGVhawirLIon/CrIN5psh3AiWpHSh4H0WeJdVpng=="], "@react-hookz/deep-equal": ["@react-hookz/deep-equal@1.0.4", "", {}, "sha512-N56fTrAPUDz/R423pag+n6TXWbvlBZDtTehaGFjK0InmN+V2OFWLE/WmORhmn6Ce7dlwH5+tQN1LJFw3ngTJVg=="], "@react-hookz/web": ["@react-hookz/web@24.0.4", "", { "dependencies": { "@react-hookz/deep-equal": "^1.0.4" }, "peerDependencies": { "js-cookie": "^3.0.5", "react": "^16.8 || ^17 || ^18", "react-dom": "^16.8 || ^17 || ^18" }, "optionalPeers": ["js-cookie"] }, "sha512-DcIM6JiZklDyHF6CRD1FTXzuggAkQ+3Ncq2Wln7Kdih8GV6ZIeN9JfS6ZaQxpQUxan8/4n0J2V/R7nMeiSrb2Q=="], - "@react-stately/flags": ["@react-stately/flags@3.1.2", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-2HjFcZx1MyQXoPqcBGALwWWmgFVUk2TuKVIQxCbRq7fPyWXIl6VHcakCLurdtYC2Iks7zizvz0Idv48MQ38DWg=="], - - "@react-stately/utils": ["@react-stately/utils@3.11.0", "", { "dependencies": { "@swc/helpers": "^0.5.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-8LZpYowJ9eZmmYLpudbo/eclIRnbhWIJZ994ncmlKlouNzKohtM8qTC6B1w1pwUbiwGdUoyzLuQbeaIor5Dvcw=="], - - "@react-types/shared": ["@react-types/shared@3.33.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-xuUpP6MyuPmJtzNOqF5pzFUIHH2YogyOQfUQHag54PRmWB7AbjuGWBUv0l1UDmz6+AbzAYGmDVAzcRDOu2PFpw=="], + "@react-types/shared": ["@react-types/shared@3.34.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-gp6xo/s2lX54AlTjOiqwDnxA7UW79BNvI9dB9pr3LZTzRKCd1ZA+ZbgKw/ReIiWuvvVw/8QFJpnqeeFyLocMcQ=="], "@renovatebot/pep440": ["@renovatebot/pep440@4.2.1", "", {}, "sha512-2FK1hF93Fuf1laSdfiEmJvSJPVIDHEUTz68D3Fi9s0IZrrpaEcj6pTFBTbYvsgC5du4ogrtf5re7yMMvrKNgkw=="], @@ -416,55 +413,55 @@ "@rollup/pluginutils": ["@rollup/pluginutils@5.3.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-walker": "^2.0.2", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q=="], - "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.59.0", "", { "os": "android", "cpu": "arm" }, "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg=="], + "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.60.4", "", { "os": "android", "cpu": "arm" }, "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ=="], - "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.59.0", "", { "os": "android", "cpu": "arm64" }, "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q=="], + "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.60.4", "", { "os": "android", "cpu": "arm64" }, "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw=="], - "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.59.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg=="], + "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.60.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA=="], - "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.59.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w=="], + "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.60.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg=="], - "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.59.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA=="], + "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.60.4", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g=="], - "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.59.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg=="], + "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.60.4", "", { "os": "freebsd", "cpu": "x64" }, "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw=="], - "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.59.0", "", { "os": "linux", "cpu": "arm" }, "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw=="], + "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.60.4", "", { "os": "linux", "cpu": "arm" }, "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA=="], - "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.59.0", "", { "os": "linux", "cpu": "arm" }, "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA=="], + "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.60.4", "", { "os": "linux", "cpu": "arm" }, "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w=="], - "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.59.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA=="], + "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.60.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg=="], - "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.59.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA=="], + "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.60.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A=="], - "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg=="], + "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.60.4", "", { "os": "linux", "cpu": "none" }, "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ=="], - "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q=="], + "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.60.4", "", { "os": "linux", "cpu": "none" }, "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw=="], - "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.59.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA=="], + "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.60.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg=="], - "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.59.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA=="], + "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.60.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A=="], - "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg=="], + "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.60.4", "", { "os": "linux", "cpu": "none" }, "sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA=="], - "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg=="], + "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.60.4", "", { "os": "linux", "cpu": "none" }, "sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw=="], - "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.59.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w=="], + "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.60.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ=="], - "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.59.0", "", { "os": "linux", "cpu": "x64" }, "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg=="], + "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.60.4", "", { "os": "linux", "cpu": "x64" }, "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ=="], - "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.59.0", "", { "os": "linux", "cpu": "x64" }, "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg=="], + "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.60.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg=="], - "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.59.0", "", { "os": "openbsd", "cpu": "x64" }, "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ=="], + "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.60.4", "", { "os": "openbsd", "cpu": "x64" }, "sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA=="], - "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.59.0", "", { "os": "none", "cpu": "arm64" }, "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA=="], + "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.60.4", "", { "os": "none", "cpu": "arm64" }, "sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg=="], - "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.59.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A=="], + "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.60.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw=="], - "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.59.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA=="], + "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.60.4", "", { "os": "win32", "cpu": "ia32" }, "sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA=="], - "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.59.0", "", { "os": "win32", "cpu": "x64" }, "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA=="], + "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.60.4", "", { "os": "win32", "cpu": "x64" }, "sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw=="], - "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.59.0", "", { "os": "win32", "cpu": "x64" }, "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA=="], + "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.60.4", "", { "os": "win32", "cpu": "x64" }, "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw=="], "@safe-global/safe-apps-provider": ["@safe-global/safe-apps-provider@0.18.6", "", { "dependencies": { "@safe-global/safe-apps-sdk": "^9.1.0", "events": "^3.3.0" } }, "sha512-4LhMmjPWlIO8TTDC2AwLk44XKXaK6hfBTWyljDm0HQ6TWlOEijVWNrt2s3OCVMSxlXAcEzYfqyu1daHZooTC2Q=="], @@ -482,7 +479,7 @@ "@socket.io/component-emitter": ["@socket.io/component-emitter@3.1.2", "", {}, "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA=="], - "@swc/helpers": ["@swc/helpers@0.5.19", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-QamiFeIK3txNjgUTNppE6MiG3p7TdninpZu0E0PbqVh1a9FNLT2FRhisaa4NcaX52XVhA5l7Pk58Ft7Sqi/2sA=="], + "@swc/helpers": ["@swc/helpers@0.5.21", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-jI/VAmtdjB/RnI8GTnokyX7Ug8c+g+ffD6QRLa6XQewtnGyukKkKSk3wLTM3b5cjt1jNh9x0jfVlagdN2gDKQg=="], "@tailwindcss/node": ["@tailwindcss/node@4.1.18", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", "jiti": "^2.6.1", "lightningcss": "1.30.2", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.1.18" } }, "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ=="], @@ -518,9 +515,9 @@ "@tanstack/react-query": ["@tanstack/react-query@5.90.21", "", { "dependencies": { "@tanstack/query-core": "5.90.20" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-0Lu6y5t+tvlTJMTO7oh5NSpJfpg/5D41LlThfepTixPYkJ0sE2Jj0m0f6yYqujBwIXlId87e234+MxG3D3g7kg=="], - "@tanstack/react-virtual": ["@tanstack/react-virtual@3.13.18", "", { "dependencies": { "@tanstack/virtual-core": "3.13.18" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-dZkhyfahpvlaV0rIKnvQiVoWPyURppl6w4m9IwMDpuIjcJ1sD9YGWrt0wISvgU7ewACXx2Ct46WPgI6qAD4v6A=="], + "@tanstack/react-virtual": ["@tanstack/react-virtual@3.13.24", "", { "dependencies": { "@tanstack/virtual-core": "3.14.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-aIJvz5OSkhNIhZIpYivrxrPTKYsjW9Uzy+sP/mx0S3sev2HyvPb7xmjbYvokzEpfgYHy/HjzJ2zFAETuUfgCpg=="], - "@tanstack/virtual-core": ["@tanstack/virtual-core@3.13.18", "", {}, "sha512-Mx86Hqu1k39icq2Zusq+Ey2J6dDWTjDvEv43PJtRCoEYTLyfaPnxIQ6iy7YAOK0NV/qOEmZQ/uCufrppZxTgcg=="], + "@tanstack/virtual-core": ["@tanstack/virtual-core@3.14.0", "", {}, "sha512-JLANqGy/D6k4Ujmh8Tr25lGimuOXNiaVyXaCAZS0W+1390sADdGnyUdSWNIfd49gebtIxGMij4IktRVzrdr12Q=="], "@testing-library/dom": ["@testing-library/dom@10.4.1", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "picocolors": "1.1.1", "pretty-format": "^27.0.2" } }, "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg=="], @@ -556,7 +553,7 @@ "@types/d3-timer": ["@types/d3-timer@3.0.2", "", {}, "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="], - "@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="], + "@types/debug": ["@types/debug@4.1.13", "", { "dependencies": { "@types/ms": "*" } }, "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw=="], "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], @@ -584,17 +581,17 @@ "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], - "@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="], + "@ungap/structured-clone": ["@ungap/structured-clone@1.3.1", "", {}, "sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ=="], "@upstash/redis": ["@upstash/redis@1.37.0", "", { "dependencies": { "uncrypto": "^0.1.3" } }, "sha512-LqOJ3+XWPLSZ2rGSed5DYG3ixybxb8EhZu3yQqF7MdZX1wLBG/FRcI6xcUZXHy/SS7mmXWyadrud0HJHkOc+uw=="], - "@vanilla-extract/css": ["@vanilla-extract/css@1.17.3", "", { "dependencies": { "@emotion/hash": "^0.9.0", "@vanilla-extract/private": "^1.0.8", "css-what": "^6.1.0", "cssesc": "^3.0.0", "csstype": "^3.0.7", "dedent": "^1.5.3", "deep-object-diff": "^1.1.9", "deepmerge": "^4.2.2", "lru-cache": "^10.4.3", "media-query-parser": "^2.0.2", "modern-ahocorasick": "^1.0.0", "picocolors": "^1.0.0" } }, "sha512-jHivr1UPoJTX5Uel4AZSOwrCf4mO42LcdmnhJtUxZaRWhW4FviFbIfs0moAWWld7GOT+2XnuVZjjA/K32uUnMQ=="], + "@vanilla-extract/css": ["@vanilla-extract/css@1.20.1", "", { "dependencies": { "@emotion/hash": "^0.9.0", "@vanilla-extract/private": "^1.0.9", "css-what": "^6.1.0", "csstype": "^3.2.3", "dedent": "^1.5.3", "deep-object-diff": "^1.1.9", "deepmerge": "^4.2.2", "lru-cache": "^10.4.3", "media-query-parser": "^2.0.2", "modern-ahocorasick": "^1.0.0", "picocolors": "^1.0.0" } }, "sha512-5I9RNo5uZW9tsBnqrWzJqELegOqTHBrZyDFnES0gR9gJJHBB9dom1N0bwITM9tKwBcfKrTX4a6DHVeQdJ2ubQA=="], - "@vanilla-extract/dynamic": ["@vanilla-extract/dynamic@2.1.4", "", { "dependencies": { "@vanilla-extract/private": "^1.0.8" } }, "sha512-7+Ot7VlP3cIzhJnTsY/kBtNs21s0YD7WI1rKJJKYP56BkbDxi/wrQUWMGEczKPUDkJuFcvbye+E2ub1u/mHH9w=="], + "@vanilla-extract/dynamic": ["@vanilla-extract/dynamic@2.1.5", "", { "dependencies": { "@vanilla-extract/private": "^1.0.9" } }, "sha512-QGIFGb1qyXQkbzx6X6i3+3LMc/iv/ZMBttMBL+Wm/DetQd36KsKsFg5CtH3qy+1hCA/5w93mEIIAiL4fkM8ycw=="], "@vanilla-extract/private": ["@vanilla-extract/private@1.0.9", "", {}, "sha512-gT2jbfZuaaCLrAxwXbRgIhGhcXbRZCG3v4TTUnjw0EJ7ArdBRxkq4msNJkbuRkCgfIK5ATmprB5t9ljvLeFDEA=="], - "@vanilla-extract/sprinkles": ["@vanilla-extract/sprinkles@1.6.4", "", { "peerDependencies": { "@vanilla-extract/css": "^1.0.0" } }, "sha512-lW3MuIcdIeHKX81DzhTnw68YJdL1ial05exiuvTLJMdHXQLKcVB93AncLPajMM6mUhaVVx5ALZzNHMTrq/U9Hg=="], + "@vanilla-extract/sprinkles": ["@vanilla-extract/sprinkles@1.6.5", "", { "peerDependencies": { "@vanilla-extract/css": "^1.0.0" } }, "sha512-HOYidLONR/SeGk8NBAeI64I4gYdsMX9vJmniL13ZcLVwawyK0s2GUENEAcGA+GYLIoeyQB61UqmhqPodJry7zA=="], "@vercel/build-utils": ["@vercel/build-utils@13.4.0", "", { "dependencies": { "@vercel/python-analysis": "0.4.1" } }, "sha512-hGBYt+olxUtDZu0W8DKl4NjY8tQC1eJUxDpkJdOhcugap4NnHeHZQ33vEKs0Z72bYlqotgu0AoEOv+ri5rlh4w=="], @@ -718,7 +715,7 @@ "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], - "baseline-browser-mapping": ["baseline-browser-mapping@2.10.0", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA=="], + "baseline-browser-mapping": ["baseline-browser-mapping@2.10.30", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-xjOFN16Ha1+Rz4nFYKqHU/LSB+gx/Vi3yQLX7r7sAW+Wa+8hhF2h4pvqTrTMc8+WcDBEunnUurr46Jvv0jk3Vg=="], "bech32": ["bech32@1.1.4", "", {}, "sha512-s0IrSOzLlbvX7yp4WBfPITzpAU8sqQcpsmwXDiKwrG4r491vwCO/XpejasRNl0piBMe/DvP4Tz0mIS/X1DPJBQ=="], @@ -730,13 +727,13 @@ "bowser": ["bowser@2.14.1", "", {}, "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg=="], - "brace-expansion": ["brace-expansion@5.0.3", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA=="], + "brace-expansion": ["brace-expansion@5.0.6", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g=="], "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], "brorand": ["brorand@1.1.0", "", {}, "sha512-cKV8tMCEpQs4hK/ik71d6LrPOnpkpGBR0wzxqr68g2m/LB2GxVYQroAjMJZRVM1Y4BCjCKc3vAamxSzOY2RP+w=="], - "browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="], + "browserslist": ["browserslist@4.28.2", "", { "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", "electron-to-chromium": "^1.5.328", "node-releases": "^2.0.36", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg=="], "bs58": ["bs58@6.0.0", "", { "dependencies": { "base-x": "^5.0.0" } }, "sha512-PD0wEnEYg6ijszw/u8s+iI3H17cTymlrwkKhDhPZq+Sokl3AU4htyBFTjAeNAlCCmg0f53g6ih3jATyCKftTfw=="], @@ -748,7 +745,7 @@ "cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="], - "call-bind": ["call-bind@1.0.8", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.0", "es-define-property": "^1.0.0", "get-intrinsic": "^1.2.4", "set-function-length": "^1.2.2" } }, "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww=="], + "call-bind": ["call-bind@1.0.9", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "get-intrinsic": "^1.3.0", "set-function-length": "^1.2.2" } }, "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ=="], "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], @@ -756,7 +753,7 @@ "camelcase": ["camelcase@5.3.1", "", {}, "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="], - "caniuse-lite": ["caniuse-lite@1.0.30001774", "", {}, "sha512-DDdwPGz99nmIEv216hKSgLD+D4ikHQHjBC/seF98N9CPqRX4M5mSxT9eTV6oyisnJcuzxtZy4n17yKKQYmYQOA=="], + "caniuse-lite": ["caniuse-lite@1.0.30001793", "", {}, "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA=="], "cbw-sdk": ["@coinbase/wallet-sdk@3.9.3", "", { "dependencies": { "bn.js": "^5.2.1", "buffer": "^6.0.3", "clsx": "^1.2.1", "eth-block-tracker": "^7.1.0", "eth-json-rpc-filters": "^6.0.0", "eventemitter3": "^5.0.1", "keccak": "^3.0.3", "preact": "^10.16.0", "sha.js": "^2.4.11" } }, "sha512-N/A2DRIf0Y3PHc1XAMvbBUu4zisna6qAdqABMZwBMNEfWrXpAwx16pZGkYCLGE+Rvv1edbcB2LYDRnACNcmCiw=="], @@ -784,7 +781,7 @@ "cli-cursor": ["cli-cursor@5.0.0", "", { "dependencies": { "restore-cursor": "^5.0.0" } }, "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw=="], - "cli-truncate": ["cli-truncate@5.1.1", "", { "dependencies": { "slice-ansi": "^7.1.0", "string-width": "^8.0.0" } }, "sha512-SroPvNHxUnk+vIW/dOSfNqdy1sPEFkrTk6TUtqLCnBlo3N7TNYYkzzN7uSD6+jVjrdO4+p8nH7JzH6cIvUem6A=="], + "cli-truncate": ["cli-truncate@5.2.0", "", { "dependencies": { "slice-ansi": "^8.0.0", "string-width": "^8.2.0" } }, "sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw=="], "cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="], @@ -816,7 +813,7 @@ "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], - "cookie-es": ["cookie-es@1.2.2", "", {}, "sha512-+W7VmiVINB+ywl1HGXJXmrqkOhpKrIiVZV6tQuV54ZyQC7MMuBt81Vc336GMLoHBq5hV/F9eXgt5Mnx0Rha5Fg=="], + "cookie-es": ["cookie-es@1.2.3", "", {}, "sha512-lXVyvUvrNXblMqzIRrxHb57UUVmqsSWlxqt3XIjCkUP0wDAf6uicO6KMbEgYrMNtEvWgWHwe42CKxPu9MYAnWw=="], "core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="], @@ -830,8 +827,6 @@ "css-what": ["css-what@6.2.2", "", {}, "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA=="], - "cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="], - "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], "cuer": ["cuer@0.0.3", "", { "dependencies": { "qr": "~0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18", "typescript": ">=5.4.0" }, "optionalPeers": ["typescript"] }, "sha512-f/UNxRMRCYtfLEGECAViByA3JNflZImOk11G9hwSd+44jvzrc99J35u5l+fbdQ2+ZG441GvOpaeGYBmWquZsbQ=="], @@ -872,7 +867,7 @@ "decode-uri-component": ["decode-uri-component@0.2.2", "", {}, "sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ=="], - "dedent": ["dedent@1.7.1", "", { "peerDependencies": { "babel-plugin-macros": "^3.1.0" }, "optionalPeers": ["babel-plugin-macros"] }, "sha512-9JmrhGZpOlEgOLdQgSm0zxFaYoQon408V1v49aqTWuXENVlnCuY9JBZcXZiCsZQWDjTm5Qf/nIvAy77mXDAjEg=="], + "dedent": ["dedent@1.7.2", "", { "peerDependencies": { "babel-plugin-macros": "^3.1.0" }, "optionalPeers": ["babel-plugin-macros"] }, "sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA=="], "deep-eql": ["deep-eql@4.1.4", "", { "dependencies": { "type-detect": "^4.0.0" } }, "sha512-SUwdGfqdKOwxCPeVYjwSyRpJ7Z+fhpwIAtmCUdZIWZ/YP5R9WAsyuSgpLVDi9bjWoN2LXHNss/dk3urXtdQxGg=="], @@ -882,7 +877,7 @@ "define-data-property": ["define-data-property@1.1.4", "", { "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", "gopd": "^1.0.1" } }, "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A=="], - "defu": ["defu@6.1.4", "", {}, "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg=="], + "defu": ["defu@6.1.7", "", {}, "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ=="], "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], @@ -892,6 +887,8 @@ "detect-browser": ["detect-browser@5.3.0", "", {}, "sha512-53rsFbGdwMwlF7qvCt0ypLM5V5/Mbl0szB7GPN8y9NCcbknYOeVVXdrXEq+90IwAfrrzt6Hd+u2E2ntakICU8w=="], + "detect-europe-js": ["detect-europe-js@0.1.2", "", {}, "sha512-lgdERlL3u0aUdHocoouzT10d9I89VVhk0qNRmll7mXdGfJT1/wqZ2ZLA4oJAjeACPY5fT1wsbq2AT+GkuInsow=="], + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], "detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="], @@ -910,11 +907,11 @@ "duplexify": ["duplexify@4.1.3", "", { "dependencies": { "end-of-stream": "^1.4.1", "inherits": "^2.0.3", "readable-stream": "^3.1.1", "stream-shift": "^1.0.2" } }, "sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA=="], - "eciesjs": ["eciesjs@0.4.17", "", { "dependencies": { "@ecies/ciphers": "^0.2.5", "@noble/ciphers": "^1.3.0", "@noble/curves": "^1.9.7", "@noble/hashes": "^1.8.0" } }, "sha512-TOOURki4G7sD1wDCjj7NfLaXZZ49dFOeEb5y39IXpb8p0hRzVvfvzZHOi5JcT+PpyAbi/Y+lxPb8eTag2WYH8w=="], + "eciesjs": ["eciesjs@0.4.18", "", { "dependencies": { "@ecies/ciphers": "^0.2.5", "@noble/ciphers": "^1.3.0", "@noble/curves": "^1.9.7", "@noble/hashes": "^1.8.0" } }, "sha512-wG99Zcfcys9fZux7Cft8BAX/YrOJLJSZ3jyYPfhZHqN2E+Ffx+QXBDsv3gubEgPtV6dTzJMSQUwk1H98/t/0wQ=="], "edge-runtime": ["edge-runtime@2.5.9", "", { "dependencies": { "@edge-runtime/format": "2.2.1", "@edge-runtime/ponyfill": "2.4.2", "@edge-runtime/vm": "3.2.0", "async-listen": "3.0.1", "mri": "1.2.0", "picocolors": "1.0.0", "pretty-ms": "7.0.1", "signal-exit": "4.0.2", "time-span": "4.0.0" }, "bin": { "edge-runtime": "dist/cli/index.js" } }, "sha512-pk+k0oK0PVXdlT4oRp4lwh+unuKB7Ng4iZ2HB+EZ7QCEQizX360Rp/F4aRpgpRgdP2ufB35N+1KppHmYjqIGSg=="], - "electron-to-chromium": ["electron-to-chromium@1.5.302", "", {}, "sha512-sM6HAN2LyK82IyPBpznDRqlTQAtuSaO+ShzFiWTvoMJLHyZ+Y39r8VMfHzwbU8MVBzQ4Wdn85+wlZl2TLGIlwg=="], + "electron-to-chromium": ["electron-to-chromium@1.5.357", "", {}, "sha512-NHlTIQDK8fmVwHwuIzmXYEJ1Ewq3D9wDNc0cWXxDGysP6Pb21giwGNkxiTifyKy/4SoPuN5l6GLP1W9Sv7zB2g=="], "elliptic": ["elliptic@6.5.4", "", { "dependencies": { "bn.js": "^4.11.9", "brorand": "^1.1.0", "hash.js": "^1.0.0", "hmac-drbg": "^1.0.1", "inherits": "^2.0.4", "minimalistic-assert": "^1.0.1", "minimalistic-crypto-utils": "^1.0.1" } }, "sha512-iLhC6ULemrljPZb+QutR5TQGB+pdW6KGD5RSegS+8sorOZT+rdQFbsQFJgvN3eRqNALqJer4oQ16YvJHlU8hzQ=="], @@ -928,7 +925,7 @@ "engine.io-parser": ["engine.io-parser@5.2.3", "", {}, "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q=="], - "enhanced-resolve": ["enhanced-resolve@5.19.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg=="], + "enhanced-resolve": ["enhanced-resolve@5.21.3", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-QyL119InA+XXEkNLNTPCXPugSvOfhwv0JOlGNzvxs0hZaiHLNvXSpudUWsOlsXGWJh8G6ckCScEkVHfX3kw/2Q=="], "environment": ["environment@1.1.0", "", {}, "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q=="], @@ -1014,7 +1011,7 @@ "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], - "get-east-asian-width": ["get-east-asian-width@1.5.0", "", {}, "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA=="], + "get-east-asian-width": ["get-east-asian-width@1.6.0", "", {}, "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA=="], "get-func-name": ["get-func-name@2.0.2", "", {}, "sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ=="], @@ -1026,13 +1023,13 @@ "get-stream": ["get-stream@8.0.1", "", {}, "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA=="], - "get-tsconfig": ["get-tsconfig@4.13.6", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw=="], + "get-tsconfig": ["get-tsconfig@4.14.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA=="], "glob": ["glob@13.0.6", "", { "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", "path-scurry": "^2.0.2" } }, "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw=="], "glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], - "goober": ["goober@2.1.18", "", { "peerDependencies": { "csstype": "^3.0.10" } }, "sha512-2vFqsaDVIT9Gz7N6kAL++pLpp41l3PfDuusHcjnGLfR6+huZkl6ziX+zgVC3ZxpqWhzH6pyDdGrCeDhMIvwaxw=="], + "goober": ["goober@2.1.19", "", { "peerDependencies": { "csstype": "^3.0.10" } }, "sha512-U7veizMqxyKlM58+Z5j2ngJBH/r9siDmxpvNxSw0PylF6WQvrASJEZrxh1hidRBJc2jqoBVSyOban5u8m+6Rxg=="], "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], @@ -1040,7 +1037,7 @@ "graphql": ["graphql@16.12.0", "", {}, "sha512-DKKrynuQRne0PNpEbzuEdHlYOMksHSUI8Zc9Unei5gTsMNA2/vMpoMz/yKba50pejK56qj98qM0SjYxAKi13gQ=="], - "h3": ["h3@1.15.5", "", { "dependencies": { "cookie-es": "^1.2.2", "crossws": "^0.3.5", "defu": "^6.1.4", "destr": "^2.0.5", "iron-webcrypto": "^1.2.1", "node-mock-http": "^1.0.4", "radix3": "^1.1.2", "ufo": "^1.6.3", "uncrypto": "^0.1.3" } }, "sha512-xEyq3rSl+dhGX2Lm0+eFQIAzlDN6Fs0EcC4f7BNUmzaRX/PTzeuM+Tr2lHB8FoXggsQIeXLj8EDVgs5ywxyxmg=="], + "h3": ["h3@1.15.11", "", { "dependencies": { "cookie-es": "^1.2.3", "crossws": "^0.3.5", "defu": "^6.1.6", "destr": "^2.0.5", "iron-webcrypto": "^1.2.1", "node-mock-http": "^1.0.4", "radix3": "^1.1.2", "ufo": "^1.6.3", "uncrypto": "^0.1.3" } }, "sha512-L3THSe2MPeBwgIZVSH5zLdBBU90TOxarvhK9d04IDY2AmVS8j2Jz2LIWtwsGOU3lu2I5jCN7FNvVfY2+XyF+mg=="], "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], @@ -1052,7 +1049,7 @@ "hash.js": ["hash.js@1.1.7", "", { "dependencies": { "inherits": "^2.0.3", "minimalistic-assert": "^1.0.1" } }, "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA=="], - "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], + "hasown": ["hasown@2.0.3", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg=="], "hast-util-to-jsx-runtime": ["hast-util-to-jsx-runtime@2.3.6", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "hast-util-whitespace": "^3.0.0", "mdast-util-mdx-expression": "^2.0.0", "mdast-util-mdx-jsx": "^3.0.0", "mdast-util-mdxjs-esm": "^2.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "style-to-js": "^1.0.0", "unist-util-position": "^5.0.0", "vfile-message": "^4.0.0" } }, "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg=="], @@ -1060,7 +1057,7 @@ "hmac-drbg": ["hmac-drbg@1.0.1", "", { "dependencies": { "hash.js": "^1.0.3", "minimalistic-assert": "^1.0.0", "minimalistic-crypto-utils": "^1.0.1" } }, "sha512-Tti3gMqLdZfhOQY1Mzf/AanLiqh1WTiJgEj26ZuYQ9fbkLomzGchCws4FyrSd4VkpBfiNhaE1On+lOz894jvXg=="], - "hono": ["hono@4.12.3", "", {}, "sha512-SFsVSjp8sj5UumXOOFlkZOG6XS9SJDKw0TbwFeV+AJ8xlST8kxK5Z/5EYa111UY8732lK2S/xB653ceuaoGwpg=="], + "hono": ["hono@4.12.19", "", {}, "sha512-xa3eYXYXx68XTT4hZ7dRzsXBhaq85ToSrlUJNoR0gwz/1Ap/CNwX47wfvV7pc/xWhjKVVkLT7zBJy8chhNguqQ=="], "html-url-attributes": ["html-url-attributes@3.0.1", "", {}, "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ=="], @@ -1108,6 +1105,8 @@ "is-regex": ["is-regex@1.2.1", "", { "dependencies": { "call-bound": "^1.0.2", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g=="], + "is-standalone-pwa": ["is-standalone-pwa@0.1.1", "", {}, "sha512-9Cbovsa52vNQCjdXOzeQq5CnCbAcRk05aU62K20WO372NrTv0NxibLFCK6lQ4/iZEFdEA3p3t2VNOn8AJ53F5g=="], + "is-stream": ["is-stream@3.0.0", "", {}, "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA=="], "is-typed-array": ["is-typed-array@1.1.15", "", { "dependencies": { "which-typed-array": "^1.1.16" } }, "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ=="], @@ -1118,7 +1117,7 @@ "isows": ["isows@1.0.7", "", { "peerDependencies": { "ws": "*" } }, "sha512-I1fSfDCZL5P0v33sVqeTDSpcstAg/N+wF5HS033mogOVIp4B+oHC7oOCsA3axAbBSGTJ8QubbNmnIRN/h8U7hg=="], - "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], + "jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="], "js-sha3": ["js-sha3@0.8.0", "", {}, "sha512-gF1cRrHhIzNfToc802P800N8PpXS+evLLXfsVpowqmAFR9uwbi89WvXg2QspOmXL8QL86J4T1EpFu+yUkwJY3Q=="], @@ -1138,7 +1137,7 @@ "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], - "jsonfile": ["jsonfile@6.2.0", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg=="], + "jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="], "keccak": ["keccak@3.0.4", "", { "dependencies": { "node-addon-api": "^2.0.0", "node-gyp-build": "^4.2.0", "readable-stream": "^3.6.0" } }, "sha512-3vKuW0jV8J3XNTzvfyicFR5qvxrSAGl7KIhvgOu5cmWwM7tZRj3fMbj/pfIf4be7aznbc+prBWGjywox/g2Y6Q=="], @@ -1176,13 +1175,13 @@ "lit-element": ["lit-element@4.2.2", "", { "dependencies": { "@lit-labs/ssr-dom-shim": "^1.5.0", "@lit/reactive-element": "^2.1.0", "lit-html": "^3.3.0" } }, "sha512-aFKhNToWxoyhkNDmWZwEva2SlQia+jfG0fjIWV//YeTaWrVnOxD89dPKfigCUspXFmjzOEUQpOkejH5Ly6sG0w=="], - "lit-html": ["lit-html@3.3.2", "", { "dependencies": { "@types/trusted-types": "^2.0.2" } }, "sha512-Qy9hU88zcmaxBXcc10ZpdK7cOLXvXpRoBxERdtqV9QOrfpMZZ6pSYP91LhpPtap3sFMUiL7Tw2RImbe0Al2/kw=="], + "lit-html": ["lit-html@3.3.3", "", { "dependencies": { "@types/trusted-types": "^2.0.2" } }, "sha512-el8M6jK2o3RXBnrSHX3ZKrsN8zEV63pSExTO1wYJz7QndGYZ8353e2a5PPX+qHe2aGayfnchQmkAojaWAREOIA=="], "local-pkg": ["local-pkg@0.5.1", "", { "dependencies": { "mlly": "^1.7.3", "pkg-types": "^1.2.1" } }, "sha512-9rrA30MRRP3gBD3HTGnC6cDFpaE1kVDWxWgqWJUN0RvDNAo+Nz/9GxB+nHOH0ifbVFy0hSA1V6vFDvnx54lTEQ=="], "locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="], - "lodash": ["lodash@4.17.23", "", {}, "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w=="], + "lodash": ["lodash@4.18.1", "", {}, "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="], "log-update": ["log-update@6.1.0", "", { "dependencies": { "ansi-escapes": "^7.0.0", "cli-cursor": "^5.0.0", "slice-ansi": "^7.1.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w=="], @@ -1280,7 +1279,7 @@ "minimalistic-crypto-utils": ["minimalistic-crypto-utils@1.0.1", "", {}, "sha512-JIYlbt6g8i5jKfJ3xz7rF0LXmv2TkDxBLUkiBeZ7bAx4GnnNMr8xFpGnOxn6GhTEHx3SjRrZEoU+j04prX1ktg=="], - "minimatch": ["minimatch@10.2.4", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg=="], + "minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], "minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], @@ -1290,7 +1289,7 @@ "mkdirp": ["mkdirp@1.0.4", "", { "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw=="], - "mlly": ["mlly@1.8.0", "", { "dependencies": { "acorn": "^8.15.0", "pathe": "^2.0.3", "pkg-types": "^1.3.1", "ufo": "^1.6.1" } }, "sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g=="], + "mlly": ["mlly@1.8.2", "", { "dependencies": { "acorn": "^8.16.0", "pathe": "^2.0.3", "pkg-types": "^1.3.1", "ufo": "^1.6.3" } }, "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA=="], "modern-ahocorasick": ["modern-ahocorasick@1.1.0", "", {}, "sha512-sEKPVl2rM+MNVkGQt3ChdmD8YsigmXdn5NifZn6jiwn9LRJpWm8F3guhaqrJT/JOat6pwpbXEk6kv+b9DMIjsQ=="], @@ -1304,9 +1303,9 @@ "multiformats": ["multiformats@9.9.0", "", {}, "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg=="], - "nano-spawn": ["nano-spawn@2.0.0", "", {}, "sha512-tacvGzUY5o2D8CBh2rrwxyNojUsZNU2zjNTzKQrkgGJQTbGAfArVWXSKMBokBeeg6C7OLRGUEyoFlYbfeWQIqw=="], + "nano-spawn": ["nano-spawn@2.1.0", "", {}, "sha512-yTW+2okrElHiH4fsiz/+/zc0EDo9BDDoC3iKk8dpv1GeRc9nUWzUZHx6TofMWErchhUQR8hY9/Eu1Uja9x1nqA=="], - "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + "nanoid": ["nanoid@3.3.12", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="], "node-addon-api": ["node-addon-api@2.0.2", "", {}, "sha512-Ntyt4AIXyaLIuMHF6IOoTakB3K+RWxwtsHNRxllEoA6vPwP9o4866g6YWDLUdnucilZhmkxiHwHr11gAENw+QA=="], @@ -1318,7 +1317,7 @@ "node-mock-http": ["node-mock-http@1.0.4", "", {}, "sha512-8DY+kFsDkNXy1sJglUfuODx1/opAGJGyrTuFqEoN90oRc2Vk0ZbD4K2qmKXBBEhZQzdKHIVfEJpDU8Ak2NJEvQ=="], - "node-releases": ["node-releases@2.0.27", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="], + "node-releases": ["node-releases@2.0.44", "", {}, "sha512-5WUyunoPMsvvEhS8AxHtRzP+oA8UCkJ7YRxatWKjngndhDGLiqEVAQKWjFAiAiuL8zMRGzGSJxFnLetoa43qGQ=="], "nopt": ["nopt@8.1.0", "", { "dependencies": { "abbrev": "^3.0.0" }, "bin": { "nopt": "bin/nopt.js" } }, "sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A=="], @@ -1374,7 +1373,7 @@ "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], - "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], + "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], "pidtree": ["pidtree@0.6.0", "", { "bin": { "pidtree": "bin/pidtree.js" } }, "sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g=="], @@ -1398,7 +1397,7 @@ "possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="], - "postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], + "postcss": ["postcss@8.5.14", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg=="], "preact": ["preact@10.24.2", "", {}, "sha512-1cSoF0aCC8uaARATfrlz4VCBqE8LwZwRfLgkxJOQwAlQt6ayTmi0D9OF7nXid1POI5SZidFuG9CnlXbDfLqY/Q=="], @@ -1416,11 +1415,11 @@ "proxy-compare": ["proxy-compare@2.6.0", "", {}, "sha512-8xuCeM3l8yqdmbPoYeLbrAXCBWu19XEYc5/F28f5qOaoAIMyfmBUkl5axiK+x9olUvRlcekvnm98AP9RDngOIw=="], - "pump": ["pump@3.0.3", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA=="], + "pump": ["pump@3.0.4", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA=="], "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], - "qr": ["qr@0.5.4", "", {}, "sha512-gjVMHOt7CX+BQd7JLQ9fnS4kJK4Lj4u+Conq52tcCbW7YH3mATTtBbTMA+7cQ1rKOkDo61olFHJReawe+XFxIA=="], + "qr": ["qr@0.6.0", "", {}, "sha512-P23VoX7SipHALdiIYG+D+LT/6n22dNKwV92FAb3d+Nlki/5WisSsfLt0UDFz2XEBtuwrECTznvu+chKKFCSYhA=="], "qrcode": ["qrcode@1.5.3", "", { "dependencies": { "dijkstrajs": "^1.0.1", "encode-utf8": "^1.0.3", "pngjs": "^5.0.0", "yargs": "^15.3.1" }, "bin": { "qrcode": "bin/qrcode" } }, "sha512-puyri6ApkEHYiVl4CFzo1tDkAZ+ATcnbJrJ6RiBM1Fhctdn/ix9MTE3hRph33omisEbC/2fcfemsseiKgBPKZg=="], @@ -1432,9 +1431,11 @@ "radix3": ["radix3@1.1.2", "", {}, "sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA=="], - "react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="], + "react": ["react@19.2.6", "", {}, "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q=="], - "react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="], + "react-aria": ["react-aria@3.48.0", "", { "dependencies": { "@internationalized/date": "^3.12.1", "@internationalized/number": "^3.6.6", "@internationalized/string": "^3.2.8", "@react-types/shared": "^3.34.0", "@swc/helpers": "^0.5.0", "aria-hidden": "^1.2.3", "clsx": "^2.0.0", "react-stately": "3.46.0", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-jQjd4rBEIMqecBaAKYJbVGK6EqIHLa5znVQ7jwFyK5vCyljoj6KhgtiahmcIPsG5vG5vEDLw+ba+bEWn6A2P4w=="], + + "react-dom": ["react-dom@19.2.6", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.6" } }, "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g=="], "react-hot-toast": ["react-hot-toast@2.6.0", "", { "dependencies": { "csstype": "^3.1.3", "goober": "^2.1.16" }, "peerDependencies": { "react": ">=16", "react-dom": ">=16" } }, "sha512-bH+2EBMZ4sdyou/DPrfgIouFpcRLCJ+HoCA32UoAYHn6T3Ur5yfcDCeSr5mwldl6pFOsiocmrXMuoCJ1vV8bWg=="], @@ -1444,7 +1445,7 @@ "react-refresh": ["react-refresh@0.18.0", "", {}, "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw=="], - "react-remove-scroll": ["react-remove-scroll@2.6.2", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.1", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-KmONPx5fnlXYJQqC62Q+lwIeAk64ws/cUw6omIumRzMRPqgnYqhSSti99nbj0Ry13bv7dF+BKn7NB+OqkdZGTw=="], + "react-remove-scroll": ["react-remove-scroll@2.7.2", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q=="], "react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="], @@ -1454,6 +1455,8 @@ "react-smooth": ["react-smooth@4.0.4", "", { "dependencies": { "fast-equals": "^5.0.1", "prop-types": "^15.8.1", "react-transition-group": "^4.4.5" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q=="], + "react-stately": ["react-stately@3.46.0", "", { "dependencies": { "@internationalized/date": "^3.12.1", "@internationalized/number": "^3.6.6", "@internationalized/string": "^3.2.8", "@react-types/shared": "^3.34.0", "@swc/helpers": "^0.5.0", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-OdxhWvHgs2L4OJGIs7hnuTr5WjjMM6enhNEAMRqiekhF8+ITvA2LRwNftOZwcogaoCslGYq5S2VQTQwnm0GbCA=="], + "react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="], "react-transition-group": ["react-transition-group@4.4.5", "", { "dependencies": { "@babel/runtime": "^7.5.5", "dom-helpers": "^5.0.1", "loose-envify": "^1.4.0", "prop-types": "^15.6.2" }, "peerDependencies": { "react": ">=16.6.0", "react-dom": ">=16.6.0" } }, "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g=="], @@ -1488,7 +1491,7 @@ "rfdc": ["rfdc@1.4.1", "", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="], - "rollup": ["rollup@4.59.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.59.0", "@rollup/rollup-android-arm64": "4.59.0", "@rollup/rollup-darwin-arm64": "4.59.0", "@rollup/rollup-darwin-x64": "4.59.0", "@rollup/rollup-freebsd-arm64": "4.59.0", "@rollup/rollup-freebsd-x64": "4.59.0", "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", "@rollup/rollup-linux-arm-musleabihf": "4.59.0", "@rollup/rollup-linux-arm64-gnu": "4.59.0", "@rollup/rollup-linux-arm64-musl": "4.59.0", "@rollup/rollup-linux-loong64-gnu": "4.59.0", "@rollup/rollup-linux-loong64-musl": "4.59.0", "@rollup/rollup-linux-ppc64-gnu": "4.59.0", "@rollup/rollup-linux-ppc64-musl": "4.59.0", "@rollup/rollup-linux-riscv64-gnu": "4.59.0", "@rollup/rollup-linux-riscv64-musl": "4.59.0", "@rollup/rollup-linux-s390x-gnu": "4.59.0", "@rollup/rollup-linux-x64-gnu": "4.59.0", "@rollup/rollup-linux-x64-musl": "4.59.0", "@rollup/rollup-openbsd-x64": "4.59.0", "@rollup/rollup-openharmony-arm64": "4.59.0", "@rollup/rollup-win32-arm64-msvc": "4.59.0", "@rollup/rollup-win32-ia32-msvc": "4.59.0", "@rollup/rollup-win32-x64-gnu": "4.59.0", "@rollup/rollup-win32-x64-msvc": "4.59.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg=="], + "rollup": ["rollup@4.60.4", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.60.4", "@rollup/rollup-android-arm64": "4.60.4", "@rollup/rollup-darwin-arm64": "4.60.4", "@rollup/rollup-darwin-x64": "4.60.4", "@rollup/rollup-freebsd-arm64": "4.60.4", "@rollup/rollup-freebsd-x64": "4.60.4", "@rollup/rollup-linux-arm-gnueabihf": "4.60.4", "@rollup/rollup-linux-arm-musleabihf": "4.60.4", "@rollup/rollup-linux-arm64-gnu": "4.60.4", "@rollup/rollup-linux-arm64-musl": "4.60.4", "@rollup/rollup-linux-loong64-gnu": "4.60.4", "@rollup/rollup-linux-loong64-musl": "4.60.4", "@rollup/rollup-linux-ppc64-gnu": "4.60.4", "@rollup/rollup-linux-ppc64-musl": "4.60.4", "@rollup/rollup-linux-riscv64-gnu": "4.60.4", "@rollup/rollup-linux-riscv64-musl": "4.60.4", "@rollup/rollup-linux-s390x-gnu": "4.60.4", "@rollup/rollup-linux-x64-gnu": "4.60.4", "@rollup/rollup-linux-x64-musl": "4.60.4", "@rollup/rollup-openbsd-x64": "4.60.4", "@rollup/rollup-openharmony-arm64": "4.60.4", "@rollup/rollup-win32-arm64-msvc": "4.60.4", "@rollup/rollup-win32-ia32-msvc": "4.60.4", "@rollup/rollup-win32-x64-gnu": "4.60.4", "@rollup/rollup-win32-x64-msvc": "4.60.4", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g=="], "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="], @@ -1524,13 +1527,13 @@ "signal-exit": ["signal-exit@4.0.2", "", {}, "sha512-MY2/qGx4enyjprQnFaZsHib3Yadh3IXyV2C321GY0pjGfVBu4un0uDJkwgdxqO+Rdx8JMT8IfJIRwbYVz3Ob3Q=="], - "slice-ansi": ["slice-ansi@7.1.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w=="], + "slice-ansi": ["slice-ansi@8.0.0", "", { "dependencies": { "ansi-styles": "^6.2.3", "is-fullwidth-code-point": "^5.1.0" } }, "sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg=="], "smol-toml": ["smol-toml@1.5.2", "", {}, "sha512-QlaZEqcAH3/RtNyet1IPIYPsEWAaYyXXv1Krsi+1L/QHppjX4Ifm8MQsBISz9vE8cHicIq3clogsheili5vhaQ=="], "socket.io-client": ["socket.io-client@4.8.3", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1", "engine.io-client": "~6.6.1", "socket.io-parser": "~4.2.4" } }, "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g=="], - "socket.io-parser": ["socket.io-parser@4.2.5", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1" } }, "sha512-bPMmpy/5WWKHea5Y/jYAP6k74A+hvmRCQaJuJB6I/ML5JZq/KfNieUVo/3Mh7SAqn7TyFdIo6wqYHInG1MU1bQ=="], + "socket.io-parser": ["socket.io-parser@4.2.6", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1" } }, "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg=="], "sonic-boom": ["sonic-boom@2.8.0", "", { "dependencies": { "atomic-sleep": "^1.0.0" } }, "sha512-kuonw1YOYYNOve5iHdSahXPOK49GqwA+LZhI6Wz/l0rP57iKyXXIHaRagOBHAPmGwJC6od2Z9zgvZ5loSgMlVg=="], @@ -1576,9 +1579,9 @@ "tailwindcss": ["tailwindcss@4.1.18", "", {}, "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="], - "tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="], + "tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="], - "tar": ["tar@7.5.9", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-BTLcK0xsDh2+PUe9F6c2TlRp4zOOBMTkoQHQIWSIzI0R7KG46uEwq4OPk2W7bZcprBMsuaeFsqwYr7pjh6CuHg=="], + "tar": ["tar@7.5.15", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-dzGK0boVlC4W5QFuQN1EFSl3bIDYsk7Tj40U6eIBnK2k/8ml7TZ5agbI5j5+qnoVcAA+rNtBml8SEiLxZpNqRQ=="], "thread-stream": ["thread-stream@0.15.2", "", { "dependencies": { "real-require": "^0.1.0" } }, "sha512-UkEhKIg2pD+fjkHQKyJO3yoIvAP3N6RlNFt2dUhcS1FGvCD1cQa1M/PGknCLFIyZdtJOWQjejp7bdNqmN7zwdA=="], @@ -1588,7 +1591,7 @@ "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], - "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], + "tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="], "tinypool": ["tinypool@0.8.4", "", {}, "sha512-i11VH5gS6IFeLY3gMBQ00/MmLncVP7JLXOw1vlgkytLmJK7QnEr7NXf0LBdxfmNPAeyetukOk0bOYrJrFGjYJQ=="], @@ -1620,9 +1623,11 @@ "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], - "ua-parser-js": ["ua-parser-js@1.0.41", "", { "bin": { "ua-parser-js": "script/cli.js" } }, "sha512-LbBDqdIC5s8iROCUjMbW1f5dJQTEFB1+KO9ogbvlb3nm9n4YHa5p4KTvFPWvh2Hs8gZMBuiB1/8+pdfe/tDPug=="], + "ua-is-frozen": ["ua-is-frozen@0.1.2", "", {}, "sha512-RwKDW2p3iyWn4UbaxpP2+VxwqXh0jpvdxsYpZ5j/MLLiQOfbsV5shpgQiw93+KMYQPcteeMQ289MaAFzs3G9pw=="], + + "ua-parser-js": ["ua-parser-js@2.0.9", "", { "dependencies": { "detect-europe-js": "^0.1.2", "is-standalone-pwa": "^0.1.1", "ua-is-frozen": "^0.1.2" }, "bin": { "ua-parser-js": "script/cli.js" } }, "sha512-OsqGhxyo/wGdLSXMSJxuMGN6H4gDnKz6Fb3IBm4bxZFMnyy0sdf6MN96Ie8tC6z/btdO+Bsy8guxlvLdwT076w=="], - "ufo": ["ufo@1.6.3", "", {}, "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q=="], + "ufo": ["ufo@1.6.4", "", {}, "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA=="], "uint8arrays": ["uint8arrays@3.1.0", "", { "dependencies": { "multiformats": "^9.4.2" } }, "sha512-ei5rfKtoRO8OyOIor2Rz5fhzjThwIHJZ3uyDPnDHTXbP0aMQ1RN/6AI5B5d9dBxJOU+BvOAk7ZQ1xphsX8Lrog=="], @@ -1646,7 +1651,7 @@ "universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="], - "unstorage": ["unstorage@1.17.4", "", { "dependencies": { "anymatch": "^3.1.3", "chokidar": "^5.0.0", "destr": "^2.0.5", "h3": "^1.15.5", "lru-cache": "^11.2.0", "node-fetch-native": "^1.6.7", "ofetch": "^1.5.1", "ufo": "^1.6.3" }, "peerDependencies": { "@azure/app-configuration": "^1.8.0", "@azure/cosmos": "^4.2.0", "@azure/data-tables": "^13.3.0", "@azure/identity": "^4.6.0", "@azure/keyvault-secrets": "^4.9.0", "@azure/storage-blob": "^12.26.0", "@capacitor/preferences": "^6 || ^7 || ^8", "@deno/kv": ">=0.9.0", "@netlify/blobs": "^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0", "@planetscale/database": "^1.19.0", "@upstash/redis": "^1.34.3", "@vercel/blob": ">=0.27.1", "@vercel/functions": "^2.2.12 || ^3.0.0", "@vercel/kv": "^1 || ^2 || ^3", "aws4fetch": "^1.0.20", "db0": ">=0.2.1", "idb-keyval": "^6.2.1", "ioredis": "^5.4.2", "uploadthing": "^7.4.4" }, "optionalPeers": ["@azure/app-configuration", "@azure/cosmos", "@azure/data-tables", "@azure/identity", "@azure/keyvault-secrets", "@azure/storage-blob", "@capacitor/preferences", "@deno/kv", "@netlify/blobs", "@planetscale/database", "@upstash/redis", "@vercel/blob", "@vercel/functions", "@vercel/kv", "aws4fetch", "db0", "idb-keyval", "ioredis", "uploadthing"] }, "sha512-fHK0yNg38tBiJKp/Vgsq4j0JEsCmgqH58HAn707S7zGkArbZsVr/CwINoi+nh3h98BRCwKvx1K3Xg9u3VV83sw=="], + "unstorage": ["unstorage@1.17.5", "", { "dependencies": { "anymatch": "^3.1.3", "chokidar": "^5.0.0", "destr": "^2.0.5", "h3": "^1.15.10", "lru-cache": "^11.2.7", "node-fetch-native": "^1.6.7", "ofetch": "^1.5.1", "ufo": "^1.6.3" }, "peerDependencies": { "@azure/app-configuration": "^1.8.0", "@azure/cosmos": "^4.2.0", "@azure/data-tables": "^13.3.0", "@azure/identity": "^4.6.0", "@azure/keyvault-secrets": "^4.9.0", "@azure/storage-blob": "^12.26.0", "@capacitor/preferences": "^6 || ^7 || ^8", "@deno/kv": ">=0.9.0", "@netlify/blobs": "^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0", "@planetscale/database": "^1.19.0", "@upstash/redis": "^1.34.3", "@vercel/blob": ">=0.27.1", "@vercel/functions": "^2.2.12 || ^3.0.0", "@vercel/kv": "^1 || ^2 || ^3", "aws4fetch": "^1.0.20", "db0": ">=0.2.1", "idb-keyval": "^6.2.1", "ioredis": "^5.4.2", "uploadthing": "^7.4.4" }, "optionalPeers": ["@azure/app-configuration", "@azure/cosmos", "@azure/data-tables", "@azure/identity", "@azure/keyvault-secrets", "@azure/storage-blob", "@capacitor/preferences", "@deno/kv", "@netlify/blobs", "@planetscale/database", "@upstash/redis", "@vercel/blob", "@vercel/functions", "@vercel/kv", "aws4fetch", "db0", "idb-keyval", "ioredis", "uploadthing"] }, "sha512-0i3iqvRfx29hkNntHyQvJTpf5W9dQ9ZadSoRU8+xVlhVtT7jAX57fazYO9EHvcRCfBCyi5YRya7XCDOsbTgkPg=="], "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], @@ -1716,7 +1721,7 @@ "yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="], - "yaml": ["yaml@2.8.2", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A=="], + "yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], "yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="], @@ -1760,7 +1765,7 @@ "@mapbox/node-pre-gyp/node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="], - "@mapbox/node-pre-gyp/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + "@mapbox/node-pre-gyp/semver": ["semver@7.8.0", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA=="], "@metamask/eth-json-rpc-provider/@metamask/json-rpc-engine": ["@metamask/json-rpc-engine@7.3.3", "", { "dependencies": { "@metamask/rpc-errors": "^6.2.1", "@metamask/safe-event-emitter": "^3.0.0", "@metamask/utils": "^8.3.0" } }, "sha512-dwZPq8wx9yV3IX2caLi9q9xZBw2XeIoYqdyihDDDpuHVCEiqadJLwqM3zy+uwf6F1QYQ65A8aOMQg1Uw7LMLNg=="], @@ -1782,7 +1787,7 @@ "@metamask/sdk-communication-layer/debug": ["debug@4.3.4", "", { "dependencies": { "ms": "2.1.2" } }, "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ=="], - "@metamask/utils/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + "@metamask/utils/semver": ["semver@7.8.0", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA=="], "@metamask/utils/uuid": ["uuid@9.0.1", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA=="], @@ -1796,20 +1801,24 @@ "@reown/appkit-wallet/zod": ["zod@3.22.4", "", {}, "sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg=="], - "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="], + "@rollup/pluginutils/@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], - "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="], + "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" }, "bundled": true }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="], - "@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.1.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ=="], + "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], - "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" }, "bundled": true }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="], + "@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="], - "@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], + "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" }, "bundled": true }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="], + + "@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="], "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], "@ts-morph/common/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], + "@types/estree-jsx/@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], + "@vercel/node/@types/node": ["@types/node@20.11.0", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-o9bjXmDNcF7GbM4CNQpmi+TutCgap/K3w1JyKgxAjqx41zp9qlIAVFi0IhCNsJcXolEqLWhbFbEeL0PvYm4pcQ=="], "@vercel/python-analysis/minimatch": ["minimatch@10.1.1", "", { "dependencies": { "@isaacs/brace-expansion": "^5.0.0" } }, "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ=="], @@ -1856,17 +1865,17 @@ "@walletconnect/window-metadata/tslib": ["tslib@1.14.1", "", {}, "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="], - "anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], + "anymatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], "cbw-sdk/clsx": ["clsx@1.2.1", "", {}, "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg=="], "cbw-sdk/eventemitter3": ["eventemitter3@5.0.1", "", {}, "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA=="], - "cbw-sdk/preact": ["preact@10.28.4", "", {}, "sha512-uKFfOHWuSNpRFVTnljsCluEFq57OKT+0QdOiQo8XWnQ/pSvg7OpX5eNOejELXJMWy+BwM2nobz0FkvzmnpCNsQ=="], + "cbw-sdk/preact": ["preact@10.29.2", "", {}, "sha512-7tNmwg/7mzzAoB/8kSg6Hl37JraAZw3Z3A0JSY7VXlZwo82Xn0G7wKbNNs2qoF4ZEEsQGTwDAroNdqKs1ofJxQ=="], "chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], - "cli-truncate/string-width": ["string-width@8.2.0", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw=="], + "cli-truncate/string-width": ["string-width@8.2.1", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA=="], "cliui/wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], @@ -1894,15 +1903,19 @@ "execa/signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], + "hast-util-to-jsx-runtime/@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], + "json-rpc-engine/@metamask/safe-event-emitter": ["@metamask/safe-event-emitter@2.0.0", "", {}, "sha512-/kSXhY692qiV1MXu6EeOZvg5nECLclxNXcKCxJ3cXQgYuRymRHpdx/t7JXfsK+JLjwA1e1c1/SBrlQYpusC29Q=="], "listr2/eventemitter3": ["eventemitter3@5.0.1", "", {}, "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA=="], - "log-update/strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="], + "log-update/slice-ansi": ["slice-ansi@7.1.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w=="], + + "log-update/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], "loose-envify/js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], - "micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], + "micromatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], "mlly/pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], @@ -1916,7 +1929,7 @@ "parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], - "path-scurry/lru-cache": ["lru-cache@11.2.6", "", {}, "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ=="], + "path-scurry/lru-cache": ["lru-cache@11.4.0", "", {}, "sha512-W+R+kFL4HgVxONq2bhXPi3bGpzGe/yEhVOp233qw9wCRtgncJ15P3bC+e4zZMu4Cq7d+WAJjXGW0uUkifhcatA=="], "pkg-types/pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], @@ -1924,7 +1937,7 @@ "porto/ox": ["ox@0.9.17", "", { "dependencies": { "@adraffy/ens-normalize": "^1.11.0", "@noble/ciphers": "^1.3.0", "@noble/curves": "1.9.1", "@noble/hashes": "^1.8.0", "@scure/bip32": "^1.7.0", "@scure/bip39": "^1.6.0", "abitype": "^1.0.9", "eventemitter3": "5.0.1" }, "peerDependencies": { "typescript": ">=5.4.0" }, "optionalPeers": ["typescript"] }, "sha512-rKAnhzhRU3Xh3hiko+i1ZxywZ55eWQzeS/Q4HRKLx2PqfHOolisZHErSsJVipGlmQKHW5qwOED/GighEw9dbLg=="], - "porto/zustand": ["zustand@5.0.11", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-fdZY+dk7zn/vbWNCYmzZULHRrss0jx5pPFiOuMZ/5HJN6Yv3u+1Wswy/4MpZEkEGhtNH+pwxZB8OKgUBPzYAGg=="], + "porto/zustand": ["zustand@5.0.13", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-efI2tVaVQPqtOh114loML/Z80Y4NP3yc+Ff0fYiZJPauNeWZeIp/bRFD7I9bfmCOYBh/PHxlglQ9+wvlwnPikQ=="], "pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], @@ -1944,7 +1957,7 @@ "to-buffer/isarray": ["isarray@2.0.5", "", {}, "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="], - "unstorage/lru-cache": ["lru-cache@11.2.6", "", {}, "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ=="], + "unstorage/lru-cache": ["lru-cache@11.4.0", "", {}, "sha512-W+R+kFL4HgVxONq2bhXPi3bGpzGe/yEhVOp233qw9wCRtgncJ15P3bC+e4zZMu4Cq7d+WAJjXGW0uUkifhcatA=="], "valtio/use-sync-external-store": ["use-sync-external-store@1.2.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0" } }, "sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA=="], @@ -1958,33 +1971,37 @@ "wrap-ansi/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], - "wrap-ansi/strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="], + "wrap-ansi/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], "@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + "@base-org/account/ox/@noble/curves": ["@noble/curves@1.9.7", "", { "dependencies": { "@noble/hashes": "1.8.0" } }, "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw=="], + "@base-org/account/ox/@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="], + "@coinbase/wallet-sdk/ox/@noble/curves": ["@noble/curves@1.9.7", "", { "dependencies": { "@noble/hashes": "1.8.0" } }, "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw=="], + "@coinbase/wallet-sdk/ox/@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="], "@metamask/eth-json-rpc-provider/@metamask/json-rpc-engine/@metamask/rpc-errors": ["@metamask/rpc-errors@6.4.0", "", { "dependencies": { "@metamask/utils": "^9.0.0", "fast-safe-stringify": "^2.0.6" } }, "sha512-1ugFO1UoirU2esS3juZanS/Fo8C8XYocCuBpfZI5N7ECtoG+zu0wF+uWZASik6CkO6w9n/Iebt4iI4pT0vptpg=="], "@metamask/eth-json-rpc-provider/@metamask/json-rpc-engine/@metamask/utils": ["@metamask/utils@8.5.0", "", { "dependencies": { "@ethereumjs/tx": "^4.2.0", "@metamask/superstruct": "^3.0.0", "@noble/hashes": "^1.3.1", "@scure/base": "^1.1.3", "@types/debug": "^4.1.7", "debug": "^4.3.4", "pony-cause": "^2.1.10", "semver": "^7.5.4", "uuid": "^9.0.1" } }, "sha512-I6bkduevXb72TIM9q2LRO63JSsF9EXduh3sBr9oybNX2hNNpr/j1tEjXrsG0Uabm4MJ1xkGAQEMwifvKZIkyxQ=="], - "@metamask/eth-json-rpc-provider/@metamask/utils/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + "@metamask/eth-json-rpc-provider/@metamask/utils/semver": ["semver@7.8.0", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA=="], "@metamask/json-rpc-engine/@metamask/rpc-errors/@metamask/utils": ["@metamask/utils@9.3.0", "", { "dependencies": { "@ethereumjs/tx": "^4.2.0", "@metamask/superstruct": "^3.1.0", "@noble/hashes": "^1.3.1", "@scure/base": "^1.1.3", "@types/debug": "^4.1.7", "debug": "^4.3.4", "pony-cause": "^2.1.10", "semver": "^7.5.4", "uuid": "^9.0.1" } }, "sha512-w8CVbdkDrVXFJbfBSlDfafDR6BAkpDmv1bC1UJVCoVny5tW2RKAdn9i68Xf7asYT4TnUhl/hN4zfUiKQq9II4g=="], - "@metamask/json-rpc-engine/@metamask/utils/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + "@metamask/json-rpc-engine/@metamask/utils/semver": ["semver@7.8.0", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA=="], "@metamask/json-rpc-engine/@metamask/utils/uuid": ["uuid@9.0.1", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA=="], - "@metamask/json-rpc-middleware-stream/@metamask/utils/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + "@metamask/json-rpc-middleware-stream/@metamask/utils/semver": ["semver@7.8.0", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA=="], "@metamask/json-rpc-middleware-stream/@metamask/utils/uuid": ["uuid@9.0.1", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA=="], "@metamask/providers/@metamask/rpc-errors/@metamask/utils": ["@metamask/utils@9.3.0", "", { "dependencies": { "@ethereumjs/tx": "^4.2.0", "@metamask/superstruct": "^3.1.0", "@noble/hashes": "^1.3.1", "@scure/base": "^1.1.3", "@types/debug": "^4.1.7", "debug": "^4.3.4", "pony-cause": "^2.1.10", "semver": "^7.5.4", "uuid": "^9.0.1" } }, "sha512-w8CVbdkDrVXFJbfBSlDfafDR6BAkpDmv1bC1UJVCoVny5tW2RKAdn9i68Xf7asYT4TnUhl/hN4zfUiKQq9II4g=="], - "@metamask/providers/@metamask/utils/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + "@metamask/providers/@metamask/utils/semver": ["semver@7.8.0", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA=="], "@metamask/providers/@metamask/utils/uuid": ["uuid@9.0.1", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA=="], @@ -2008,12 +2025,14 @@ "@reown/appkit/@walletconnect/universal-provider/@walletconnect/utils": ["@walletconnect/utils@2.21.0", "", { "dependencies": { "@noble/ciphers": "1.2.1", "@noble/curves": "1.8.1", "@noble/hashes": "1.7.1", "@walletconnect/jsonrpc-utils": "1.0.8", "@walletconnect/keyvaluestorage": "1.1.1", "@walletconnect/relay-api": "1.0.11", "@walletconnect/relay-auth": "1.1.0", "@walletconnect/safe-json": "1.0.2", "@walletconnect/time": "1.0.2", "@walletconnect/types": "2.21.0", "@walletconnect/window-getters": "1.0.1", "@walletconnect/window-metadata": "1.0.1", "bs58": "6.0.0", "detect-browser": "5.3.0", "query-string": "7.1.3", "uint8arrays": "3.1.0", "viem": "2.23.2" } }, "sha512-zfHLiUoBrQ8rP57HTPXW7rQMnYxYI4gT9yTACxVW6LhIFROTF6/ytm5SKNoIvi4a5nX5dfXG4D9XwQUCu8Ilig=="], - "@ts-morph/common/minimatch/brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="], + "@ts-morph/common/minimatch/brace-expansion": ["brace-expansion@1.1.14", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g=="], "@vercel/node/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="], "@vitest/snapshot/pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], + "@vitest/utils/estree-walker/@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], + "@vitest/utils/pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], "@walletconnect/jsonrpc-http-connection/cross-fetch/node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="], @@ -2030,14 +2049,18 @@ "@walletconnect/utils/viem/ws": ["ws@8.18.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="], - "cli-truncate/string-width/strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="], + "cli-truncate/string-width/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], - "eth-block-tracker/@metamask/utils/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + "eth-block-tracker/@metamask/utils/semver": ["semver@7.8.0", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA=="], "ethereum-cryptography/@scure/bip32/@scure/base": ["@scure/base@1.1.9", "", {}, "sha512-8YKhl8GHiNI/pU2VMaofa2Tor7PJRAjwQLBBuilkJ9L5+13yVbC7JO/wS7piioAvPSwR3JKM1IJ/u4xQzbcXKg=="], "ethereum-cryptography/@scure/bip39/@scure/base": ["@scure/base@1.1.9", "", {}, "sha512-8YKhl8GHiNI/pU2VMaofa2Tor7PJRAjwQLBBuilkJ9L5+13yVbC7JO/wS7piioAvPSwR3JKM1IJ/u4xQzbcXKg=="], + "log-update/slice-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], + + "log-update/slice-ansi/is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="], + "log-update/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], "obj-multiplex/readable-stream/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="], @@ -2062,15 +2085,15 @@ "@metamask/eth-json-rpc-provider/@metamask/json-rpc-engine/@metamask/rpc-errors/@metamask/utils": ["@metamask/utils@9.3.0", "", { "dependencies": { "@ethereumjs/tx": "^4.2.0", "@metamask/superstruct": "^3.1.0", "@noble/hashes": "^1.3.1", "@scure/base": "^1.1.3", "@types/debug": "^4.1.7", "debug": "^4.3.4", "pony-cause": "^2.1.10", "semver": "^7.5.4", "uuid": "^9.0.1" } }, "sha512-w8CVbdkDrVXFJbfBSlDfafDR6BAkpDmv1bC1UJVCoVny5tW2RKAdn9i68Xf7asYT4TnUhl/hN4zfUiKQq9II4g=="], - "@metamask/eth-json-rpc-provider/@metamask/json-rpc-engine/@metamask/utils/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + "@metamask/eth-json-rpc-provider/@metamask/json-rpc-engine/@metamask/utils/semver": ["semver@7.8.0", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA=="], "@metamask/eth-json-rpc-provider/@metamask/json-rpc-engine/@metamask/utils/uuid": ["uuid@9.0.1", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA=="], - "@metamask/json-rpc-engine/@metamask/rpc-errors/@metamask/utils/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + "@metamask/json-rpc-engine/@metamask/rpc-errors/@metamask/utils/semver": ["semver@7.8.0", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA=="], "@metamask/json-rpc-engine/@metamask/rpc-errors/@metamask/utils/uuid": ["uuid@9.0.1", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA=="], - "@metamask/providers/@metamask/rpc-errors/@metamask/utils/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + "@metamask/providers/@metamask/rpc-errors/@metamask/utils/semver": ["semver@7.8.0", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA=="], "@metamask/providers/@metamask/rpc-errors/@metamask/utils/uuid": ["uuid@9.0.1", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA=="], @@ -2106,7 +2129,7 @@ "@ts-morph/common/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], - "@walletconnect/utils/viem/ox/@noble/curves": ["@noble/curves@1.9.1", "", { "dependencies": { "@noble/hashes": "1.8.0" } }, "sha512-k11yZxZg+t+gWvBbIswW0yoJlu8cHOC7dhunwOzoWH/mXGBiYyR4YY6hAEK/3EUs4UpB8la1RfdRpeGsFHkWsA=="], + "@walletconnect/utils/viem/ox/@noble/curves": ["@noble/curves@1.9.7", "", { "dependencies": { "@noble/hashes": "1.8.0" } }, "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw=="], "@walletconnect/utils/viem/ox/@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="], @@ -2214,7 +2237,7 @@ "vitest/vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.21.5", "", { "os": "win32", "cpu": "x64" }, "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw=="], - "@metamask/eth-json-rpc-provider/@metamask/json-rpc-engine/@metamask/rpc-errors/@metamask/utils/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + "@metamask/eth-json-rpc-provider/@metamask/json-rpc-engine/@metamask/rpc-errors/@metamask/utils/semver": ["semver@7.8.0", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA=="], "@metamask/eth-json-rpc-provider/@metamask/json-rpc-engine/@metamask/rpc-errors/@metamask/utils/uuid": ["uuid@9.0.1", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA=="], @@ -2254,7 +2277,9 @@ "@reown/appkit/@walletconnect/universal-provider/@walletconnect/utils/viem/ws": ["ws@8.18.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="], - "@reown/appkit-controllers/@walletconnect/universal-provider/@walletconnect/utils/viem/ox/@noble/curves": ["@noble/curves@1.9.1", "", { "dependencies": { "@noble/hashes": "1.8.0" } }, "sha512-k11yZxZg+t+gWvBbIswW0yoJlu8cHOC7dhunwOzoWH/mXGBiYyR4YY6hAEK/3EUs4UpB8la1RfdRpeGsFHkWsA=="], + "@walletconnect/utils/viem/ox/@scure/bip32/@noble/curves": ["@noble/curves@1.9.1", "", { "dependencies": { "@noble/hashes": "1.8.0" } }, "sha512-k11yZxZg+t+gWvBbIswW0yoJlu8cHOC7dhunwOzoWH/mXGBiYyR4YY6hAEK/3EUs4UpB8la1RfdRpeGsFHkWsA=="], + + "@reown/appkit-controllers/@walletconnect/universal-provider/@walletconnect/utils/viem/ox/@noble/curves": ["@noble/curves@1.9.7", "", { "dependencies": { "@noble/hashes": "1.8.0" } }, "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw=="], "@reown/appkit-controllers/@walletconnect/universal-provider/@walletconnect/utils/viem/ox/@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="], @@ -2266,7 +2291,7 @@ "@reown/appkit-controllers/@walletconnect/universal-provider/@walletconnect/utils/viem/ox/eventemitter3": ["eventemitter3@5.0.1", "", {}, "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA=="], - "@reown/appkit-utils/@walletconnect/universal-provider/@walletconnect/utils/viem/ox/@noble/curves": ["@noble/curves@1.9.1", "", { "dependencies": { "@noble/hashes": "1.8.0" } }, "sha512-k11yZxZg+t+gWvBbIswW0yoJlu8cHOC7dhunwOzoWH/mXGBiYyR4YY6hAEK/3EUs4UpB8la1RfdRpeGsFHkWsA=="], + "@reown/appkit-utils/@walletconnect/universal-provider/@walletconnect/utils/viem/ox/@noble/curves": ["@noble/curves@1.9.7", "", { "dependencies": { "@noble/hashes": "1.8.0" } }, "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw=="], "@reown/appkit-utils/@walletconnect/universal-provider/@walletconnect/utils/viem/ox/@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="], @@ -2278,7 +2303,7 @@ "@reown/appkit-utils/@walletconnect/universal-provider/@walletconnect/utils/viem/ox/eventemitter3": ["eventemitter3@5.0.1", "", {}, "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA=="], - "@reown/appkit/@walletconnect/universal-provider/@walletconnect/utils/viem/ox/@noble/curves": ["@noble/curves@1.9.1", "", { "dependencies": { "@noble/hashes": "1.8.0" } }, "sha512-k11yZxZg+t+gWvBbIswW0yoJlu8cHOC7dhunwOzoWH/mXGBiYyR4YY6hAEK/3EUs4UpB8la1RfdRpeGsFHkWsA=="], + "@reown/appkit/@walletconnect/universal-provider/@walletconnect/utils/viem/ox/@noble/curves": ["@noble/curves@1.9.7", "", { "dependencies": { "@noble/hashes": "1.8.0" } }, "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw=="], "@reown/appkit/@walletconnect/universal-provider/@walletconnect/utils/viem/ox/@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="], @@ -2289,5 +2314,11 @@ "@reown/appkit/@walletconnect/universal-provider/@walletconnect/utils/viem/ox/abitype": ["abitype@1.2.3", "", { "peerDependencies": { "typescript": ">=5.0.4", "zod": "^3.22.0 || ^4.0.0" }, "optionalPeers": ["typescript", "zod"] }, "sha512-Ofer5QUnuUdTFsBRwARMoWKOH1ND5ehwYhJ3OJ/BQO+StkwQjHw0XyVh4vDttzHB7QOFhPHa/o413PJ82gU/Tg=="], "@reown/appkit/@walletconnect/universal-provider/@walletconnect/utils/viem/ox/eventemitter3": ["eventemitter3@5.0.1", "", {}, "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA=="], + + "@reown/appkit-controllers/@walletconnect/universal-provider/@walletconnect/utils/viem/ox/@scure/bip32/@noble/curves": ["@noble/curves@1.9.1", "", { "dependencies": { "@noble/hashes": "1.8.0" } }, "sha512-k11yZxZg+t+gWvBbIswW0yoJlu8cHOC7dhunwOzoWH/mXGBiYyR4YY6hAEK/3EUs4UpB8la1RfdRpeGsFHkWsA=="], + + "@reown/appkit-utils/@walletconnect/universal-provider/@walletconnect/utils/viem/ox/@scure/bip32/@noble/curves": ["@noble/curves@1.9.1", "", { "dependencies": { "@noble/hashes": "1.8.0" } }, "sha512-k11yZxZg+t+gWvBbIswW0yoJlu8cHOC7dhunwOzoWH/mXGBiYyR4YY6hAEK/3EUs4UpB8la1RfdRpeGsFHkWsA=="], + + "@reown/appkit/@walletconnect/universal-provider/@walletconnect/utils/viem/ox/@scure/bip32/@noble/curves": ["@noble/curves@1.9.1", "", { "dependencies": { "@noble/hashes": "1.8.0" } }, "sha512-k11yZxZg+t+gWvBbIswW0yoJlu8cHOC7dhunwOzoWH/mXGBiYyR4YY6hAEK/3EUs4UpB8la1RfdRpeGsFHkWsA=="], } } diff --git a/docs/portfolio/simple-protocol-return-annualized-return-chart.md b/docs/portfolio/simple-protocol-return-annualized-return-chart.md new file mode 100644 index 000000000..bd54c79db --- /dev/null +++ b/docs/portfolio/simple-protocol-return-annualized-return-chart.md @@ -0,0 +1,72 @@ +# Simple Protocol Return: Annualized % Chart + +## One-Line Definition + +`Annualized %` shows the wallet's simple protocol return converted into a per-year rate using time-weighted baseline exposure. + +## What It Answers + +It answers: + +> When capital was actually sitting in this wallet, what annualized protocol return rate did it earn? + +It does **not** answer: + +- last-12-months APY +- current vault APY +- a trailing market return + +## How We Calculate It + +We first measure baseline capital at work through time: + +```text +baselineExposureWeightUsdYears = + integral over time of (baselineUnderlying * receiptPriceUsd) + --------------------------------------------------------------- + seconds per year +``` + +Then we annualize total simple growth against that exposure: + +```text +annualizedProtocolReturnPct = + growthWeightUsd / baselineExposureWeightUsdYears * 100 +``` + +Where: + +- `growthWeightUsd` is cumulative simple protocol growth so far +- `baselineExposureWeightUsdYears` is dollar-years of baseline capital actually at work + +## How To Read It + +- Upward moves mean the wallet is earning more growth per unit of time-weighted capital. +- Flat periods mean the effective annualized rate is stable. +- Downward moves can happen when new capital enters and has not had time to earn much yet. + +That makes this metric much better than `Cumulative %` for operating wallets where money regularly comes in and goes out. + +Each chart point is cumulative up to that date. The timeframe selector changes which dates are shown, not the fact that the value is cumulative. + +## Why It Exists + +For a payroll or treasury wallet, cumulative receipts can be much larger than the capital that was actually invested at any one time. + +Annualizing against time-weighted exposure fixes that. + +It gives a better answer to: + +> What return rate did this wallet experience while capital was actually in Yearn? + +## Main Limitation + +It is still a simple protocol-return metric: + +- it ignores later asset price moves +- it is not a market PnL figure +- it is not a trailing APY for just the visible chart window + +## Best Short Explanation + +`Annualized %` is simple protocol growth converted into a per-year rate using time-weighted baseline capital actually at work. diff --git a/docs/portfolio/simple-protocol-return-cumulative-return-chart.md b/docs/portfolio/simple-protocol-return-cumulative-return-chart.md new file mode 100644 index 000000000..d0688daaa --- /dev/null +++ b/docs/portfolio/simple-protocol-return-cumulative-return-chart.md @@ -0,0 +1,67 @@ +# Simple Protocol Return: Cumulative % Chart + +## One-Line Definition + +`Cumulative %` shows total protocol growth so far divided by the total baseline capital that has entered the wallet so far, using receipt-time USD weighting. + +## What It Answers + +It answers: + +> Relative to all baseline capital that has passed through this wallet, how much protocol return has been earned so far? + +It does **not** answer: + +- trailing return for just the visible chart window +- a flow-neutral performance index +- annualized yield + +## How We Calculate It + +At each point in time: + +```text +baselineWeightUsd = sum(baselineUnderlying * receiptPriceUsd) +growthWeightUsd = sum(growthUnderlying * receiptPriceUsd) + +cumulativeReturnPct = growthWeightUsd / baselineWeightUsd * 100 +``` + +`baselineWeightUsd` includes both: + +- baseline from lots that are still open +- baseline from lots that were already exited + +So this is a cumulative return on all baseline capital seen so far. + +## How To Read It + +- Upward moves mean protocol growth is compounding faster than baseline is increasing. +- Flat periods mean growth is not changing much relative to the existing baseline. +- Downward moves can happen when new capital enters the wallet. + +That last point is important: + +- a new deposit increases the denominator immediately +- but it does not bring instant growth with it +- so the percentage can step down even when the wallet did nothing wrong + +Each chart point is cumulative up to that date. The timeframe selector changes which dates are shown, not the fact that the value is cumulative. + +## Why It Is Useful + +This is the cleanest answer to: + +> Across all the capital that has passed through this wallet, what simple protocol return has Yearn produced? + +It is especially useful for stablecoin wallets where the receipt-time USD weighting is easy to reason about. + +## Main Limitation + +Because new inflows increase the denominator, `Cumulative %` is not the best chart for flow-neutral performance comparisons. + +That is why `Growth Index` exists. + +## Best Short Explanation + +`Cumulative %` is total simple protocol growth divided by total baseline capital received so far. diff --git a/docs/portfolio/simple-protocol-return-growth-chart.md b/docs/portfolio/simple-protocol-return-growth-chart.md new file mode 100644 index 000000000..c90298668 --- /dev/null +++ b/docs/portfolio/simple-protocol-return-growth-chart.md @@ -0,0 +1,70 @@ +# Simple Protocol Return: Growth Chart + +## One-Line Definition + +`Growth` shows how much protocol yield the wallet has earned so far, converted into USD using the underlying token price at the time each lot entered the wallet. + +## What It Answers + +It answers: + +> How much value did Yearn add while this wallet held vault shares? + +It does **not** answer: + +- current mark-to-market PnL +- real USD profit after the asset price moved +- tax/accounting profit + +## How We Calculate It + +For each receipt lot, we store: + +- `baselineUnderlying` +- `receiptPriceUsd` + +For an open lot at time `t`: + +```text +currentUnderlying = sharesRemaining * PPS(t) +growthUnderlying = currentUnderlying - baselineUnderlyingRemaining +growthWeightUsd = growthUnderlying * receiptPriceUsd +``` + +For an exited lot: + +```text +exitUnderlying = withdrawal assets, or shares exited * PPS(exit) +realizedGrowthUnderlying = exitUnderlying - baselineUnderlyingConsumed +realizedGrowthWeightUsd = realizedGrowthUnderlying * receiptPriceUsd +``` + +The chart point is the sum of realized and unrealized `growthWeightUsd` across all vault families. + +## How To Read It + +- Upward moves mean PPS increased on positions the wallet was holding. +- A flat line means no additional protocol growth was earned in that period. +- Deposits should add new baseline, not instant growth. +- Exits should realize existing growth, not create fake jumps. + +Each chart point is cumulative up to that date. The timeframe selector changes which dates are shown, not the fact that the value is cumulative. + +## Why It Can Look Strange On Volatile Wallets + +The chart is USD-shaped, but it is **not** using the current asset price. + +It uses the price at receipt time: + +```text +growthWeightUsd = protocol-added underlying * receipt-time USD price +``` + +That means a volatile vault can show positive `Growth` even if the current market value of the position is down. + +For stablecoin vaults this is intuitive. +For ETH-like or mixed wallets it is often better to pair this chart with `Growth Index`. + +## Best Short Explanation + +`Growth` is protocol-earned value, expressed in receipt-time dollars, while the wallet held the vault shares. diff --git a/docs/portfolio/simple-protocol-return-growth-index-chart.md b/docs/portfolio/simple-protocol-return-growth-index-chart.md new file mode 100644 index 000000000..f8eb51739 --- /dev/null +++ b/docs/portfolio/simple-protocol-return-growth-index-chart.md @@ -0,0 +1,74 @@ +# Simple Protocol Return: Growth Index Chart + +## One-Line Definition + +`Growth Index` is the flow-neutral performance chart. It starts at `100` and compounds simple protocol return through time without stepping down when new positions are opened. + +## What It Answers + +It answers: + +> How did protocol return compound through time, after neutralizing deposits and withdrawals? + +It does **not** answer: + +- total balance +- dollar PnL +- a sum of the family lines + +## How We Calculate It + +At each interval between two chart points: + +```text +deltaGrowth = growthWeightUsd[t] - growthWeightUsd[t-1] +deltaExposureYears = exposureYears[t] - exposureYears[t-1] +intervalYears = secondsBetweenPoints / secondsPerYear + +intervalReturn = deltaGrowth * intervalYears / deltaExposureYears +nextIndex = previousIndex * (1 + intervalReturn) +``` + +The index: + +- starts at `100` once the wallet has capital at work +- compounds interval by interval +- stays level if there is no incremental protocol return + +## Why It Behaves Better Than Cumulative % + +`Cumulative %` can step down when a large new position opens, because the denominator jumps. + +`Growth Index` avoids that by compounding interval returns instead of re-dividing cumulative growth by cumulative baseline at every point. + +That makes it the best single chart for comparing protocol-return behavior through time. + +## How To Read It + +- `100` means the starting level +- `110` means protocol return has compounded about 10% from the start level +- `125` means about 25% compounded protocol-return growth from the start level + +The wallet line is: + +- the aggregate portfolio-level index +- not the sum of the vault lines +- not an equal-weight average of the vault lines + +The family lines are: + +- the largest selected vault families +- normalized to the same starting scale +- shown only while that vault still has an open position + +## Why It Is Useful + +This is the best chart for mixed wallets, especially when a wallet contains volatile assets and raw dollar `Growth` is hard to interpret. + +It gives you one clean answer to: + +> Did protocol return keep compounding, and which vaults did better or worse? + +## Best Short Explanation + +`Growth Index` is a normalized, flow-neutral protocol-return chart that starts at `100` and compounds through time. diff --git a/package.json b/package.json index f111800fb..f2a990ec1 100644 --- a/package.json +++ b/package.json @@ -2,13 +2,13 @@ "name": "yearnfi", "version": "0.3.7", "scripts": { - "dev": "bun scripts/run-local.js dev", + "dev": "bun scripts/dev.mjs", "dev:client": "vite", "dev:server": "bun --watch api/server.ts", "dev:ts": "tsc --watch", "tenderly": "bun scripts/tenderly.ts", "build": "tsc && vite build", - "preview": "bun scripts/run-local.js preview", + "preview": "bun scripts/preview.mjs", "preview:client": "vite preview", "preview:server": "bun scripts/ensure-api-server.js", "lint": "biome check .", @@ -20,19 +20,19 @@ "prepare": "husky" }, "dependencies": { - "@headlessui/react": "2.2.9", - "@plausible-analytics/tracker": "0.4.4", - "@rainbow-me/rainbowkit": "2.2.10", + "@headlessui/react": "2.2.10", + "@plausible-analytics/tracker": "0.4.5", + "@rainbow-me/rainbowkit": "2.2.11", "@react-hookz/web": "24.0.4", "@tanstack/react-query": "5.90.21", - "@tanstack/react-virtual": "3.13.18", + "@tanstack/react-virtual": "3.13.24", "@upstash/redis": "1.37.0", "cross-fetch": "4.1.0", "ethers": "5.7.2", "framer-motion": "12.34.0", "graphql": "16.12.0", - "react": "19.2.4", - "react-dom": "19.2.4", + "react": "19.2.6", + "react-dom": "19.2.6", "react-hot-toast": "2.6.0", "react-markdown": "10.1.0", "react-rewards": "2.1.0", @@ -45,7 +45,7 @@ "zod": "4.3.6" }, "devDependencies": { - "@biomejs/biome": "2.4.0", + "@biomejs/biome": "2.4.14", "@tailwindcss/postcss": "4.1.18", "@testing-library/react": "16.3.2", "@types/minimatch": "6.0.0", @@ -58,7 +58,7 @@ "concurrently": "9.2.1", "husky": "9.1.7", "lint-staged": "16.2.7", - "postcss": "8.5.6", + "postcss": "8.5.14", "tailwindcss": "4.1.18", "typescript": "5.9.3", "vite": "7.3.2", diff --git a/public/yvBTC-1.png b/public/yvBTC-1.png new file mode 100644 index 000000000..9bc29c341 Binary files /dev/null and b/public/yvBTC-1.png differ diff --git a/public/yvBTC-1.svg b/public/yvBTC-1.svg new file mode 100644 index 000000000..18df49a27 --- /dev/null +++ b/public/yvBTC-1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/yvusd-128.png b/public/yvusd-128.png new file mode 100644 index 000000000..aa5ac4402 Binary files /dev/null and b/public/yvusd-128.png differ diff --git a/public/yvusd-full.png b/public/yvusd-full.png new file mode 100644 index 000000000..201098c0d Binary files /dev/null and b/public/yvusd-full.png differ diff --git a/scripts/api-runtime.mjs b/scripts/api-runtime.mjs new file mode 100644 index 000000000..bde447947 --- /dev/null +++ b/scripts/api-runtime.mjs @@ -0,0 +1,771 @@ +import { spawnSync } from 'node:child_process' +import { createHash } from 'node:crypto' +import { existsSync, mkdirSync, readFileSync, realpathSync, statSync, writeFileSync } from 'node:fs' +import { createServer } from 'node:net' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { cwd, env, exit, stdin as input, stdout as output } from 'node:process' +import { createInterface } from 'node:readline/promises' +import { loadEnv } from 'vite' + +const DEFAULT_API_PORT = 3001 +const API_HEALTHCHECK_PATH = '/api/enso/balances' +const API_HEALTHCHECK_EXPECTED_ERROR = 'Missing eoaAddress' +const API_HEALTHCHECK_TIMEOUT_MS = 500 +const LOOPBACK_HOSTNAMES = new Set(['localhost', '127.0.0.1', '0.0.0.0', '::1', '[::1]']) +const API_RUNTIME_STATE_DIR = join(tmpdir(), 'yearn-api-runtime') +export const DEFAULT_API_ENV_CHANGE_PATHS = ['.env', '.env.local'] + +export const DEFAULT_API_CHANGE_PATHS = [ + 'api', + 'vite.config.ts', + 'scripts/ensure-api-server.js', + 'scripts/api-runtime.mjs', + 'package.json' +] + +function resolveRealPath(path) { + try { + return realpathSync(path) + } catch { + return path + } +} + +function readWorkspacePathForPid(pid) { + const procCwdPath = `/proc/${pid}/cwd` + if (existsSync(procCwdPath)) { + return resolveRealPath(procCwdPath) + } + + return runCommand('lsof', ['-a', '-p', String(pid), '-d', 'cwd', '-Fn']) + ?.stdout.split('\n') + .map((line) => line.trim()) + .find((line) => line.startsWith('n')) + ?.slice(1) +} + +export function resolveLauncherEnv( + mode, + { + envDir = cwd(), + shellEnv = Object.fromEntries(Object.entries(env).filter((entry) => typeof entry[1] === 'string')) + } = {} +) { + return { + ...loadEnv(mode, envDir, ''), + ...shellEnv + } +} + +function getModeEnvChangePaths(mode) { + return Array.from(new Set([...DEFAULT_API_ENV_CHANGE_PATHS, `.env.${mode}`, `.env.${mode}.local`])) +} + +export function getApiChangePathsForMode(mode) { + return [...DEFAULT_API_CHANGE_PATHS, ...getModeEnvChangePaths(mode)] +} + +function resolveApiRuntimeStatePath(workspacePath, port) { + const workspaceKey = createHash('sha1').update(workspacePath).digest('hex') + return join(API_RUNTIME_STATE_DIR, `${workspaceKey}-${port}.json`) +} + +function readApiRuntimeState(workspacePath, port) { + const statePath = resolveApiRuntimeStatePath(workspacePath, port) + + if (!existsSync(statePath)) { + return undefined + } + + try { + return JSON.parse(readFileSync(statePath, 'utf8')) + } catch { + return undefined + } +} + +function writeApiRuntimeState({ workspacePath, port, pid, mode, head, startedAtMs }) { + mkdirSync(API_RUNTIME_STATE_DIR, { recursive: true }) + writeFileSync( + resolveApiRuntimeStatePath(workspacePath, port), + JSON.stringify({ pid, mode, head, startedAtMs }), + 'utf8' + ) +} + +function getCurrentGitHead() { + const gitHeadResult = runCommand('git', ['rev-parse', 'HEAD']) + + if (!gitHeadResult || gitHeadResult.status !== 0) { + return undefined + } + + const gitHead = gitHeadResult.stdout.trim() + return gitHead || undefined +} + +function readProcessStartedAtMs(pid) { + const startedAtResult = runCommand('ps', ['-p', String(pid), '-o', 'lstart=']) + + if (!startedAtResult || startedAtResult.status !== 0) { + return undefined + } + + const startedAtParts = startedAtResult.stdout.trim().split(/\s+/) + if (startedAtParts.length < 5) { + return undefined + } + + const [, month, day, time, year] = startedAtParts + const startedAtMs = Date.parse(`${month} ${day} ${year} ${time}`) + + return Number.isNaN(startedAtMs) ? undefined : startedAtMs +} + +export function getEnvChangeEntriesSince(mode, startedAtMs, envDir = cwd()) { + const envPaths = getModeEnvChangePaths(mode) + + return envPaths + .map((relativePath) => ({ + relativePath, + absolutePath: join(envDir, relativePath) + })) + .filter(({ absolutePath }) => existsSync(absolutePath)) + .flatMap(({ relativePath, absolutePath }) => { + if (startedAtMs === undefined) { + return [`M ${relativePath} (could not verify against the running API process start time)`] + } + + return statSync(absolutePath).mtimeMs > startedAtMs + ? [`M ${relativePath} (newer than the running API process)`] + : [] + }) +} + +export function getRecordedApiRuntimeMismatchEntries({ workspacePath, port, pid, mode, processStartedAtMs }) { + const recordedRuntimeState = readApiRuntimeState(workspacePath, port) + + if (!recordedRuntimeState) { + return ['M could not verify the running API launch context for this workspace'] + } + + if (recordedRuntimeState.pid !== pid) { + return ['M the running API pid does not match the last launcher-managed API process for this workspace'] + } + + if (processStartedAtMs === undefined || recordedRuntimeState.startedAtMs !== processStartedAtMs) { + return ['M could not verify that the running API process matches the last launcher-managed start'] + } + + if (recordedRuntimeState.mode !== mode) { + return [`M the running API was started in ${recordedRuntimeState.mode} mode, not ${mode}`] + } + + return [] +} + +export function getRecordedApiCommittedChangeEntries({ + workspacePath, + port, + currentHead, + changePaths, + runCommandImpl = runCommand +}) { + const recordedRuntimeState = readApiRuntimeState(workspacePath, port) + + if (!recordedRuntimeState?.head || !currentHead) { + return ['M could not verify API-related commits since the running API started'] + } + + if (recordedRuntimeState.head === currentHead) { + return [] + } + + const diffResult = runCommandImpl('git', [ + 'diff', + '--name-status', + `${recordedRuntimeState.head}..${currentHead}`, + '--', + ...changePaths + ]) + + if (!diffResult || diffResult.status !== 0) { + return ['M could not verify API-related commits since the running API started'] + } + + return diffResult.stdout + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) +} + +export async function resolveLocalApiRuntimeOwner({ + apiPort, + apiProxyTarget, + checkApiHealthImpl = checkApiHealth, + inspectPortOwnerImpl = inspectPortOwner, + readProcessStartedAtMsImpl = readProcessStartedAtMs, + timeoutMs = 5_000, + pollIntervalMs = 100 +}) { + const deadline = Date.now() + timeoutMs + + while (Date.now() <= deadline) { + const health = await checkApiHealthImpl(apiProxyTarget) + + if (health.ok) { + const portOwner = inspectPortOwnerImpl(apiPort) + if (portOwner?.pid) { + return { + pid: portOwner.pid, + startedAtMs: readProcessStartedAtMsImpl(portOwner.pid) + } + } + } + + await new Promise((resolve) => setTimeout(resolve, pollIntervalMs)) + } + + return undefined +} + +function normalizePort(portValue, fallbackPort = DEFAULT_API_PORT) { + const port = Number(portValue) + return Number.isInteger(port) && port > 0 ? port : fallbackPort +} + +function isLoopbackHostname(hostname) { + return LOOPBACK_HOSTNAMES.has(hostname.trim().toLowerCase()) +} + +function getLocalApiProxyTarget(apiPort, apiProxyTarget) { + try { + const parsedTarget = new URL(apiProxyTarget) + if (parsedTarget.protocol !== 'http:' || !isLoopbackHostname(parsedTarget.hostname)) { + return `http://localhost:${apiPort}` + } + + parsedTarget.port = String(apiPort) + return parsedTarget.toString().replace(/\/$/, '') + } catch { + return `http://localhost:${apiPort}` + } +} + +export function resolveConfiguredApiRuntime(launcherEnv) { + const explicitTarget = launcherEnv.API_PROXY_TARGET?.trim() || launcherEnv.VITE_API_PROXY_TARGET?.trim() + + if (explicitTarget) { + try { + const parsedTarget = new URL(explicitTarget) + const inferredPort = normalizePort( + parsedTarget.port || (parsedTarget.protocol === 'https:' ? '443' : '80'), + DEFAULT_API_PORT + ) + + return { + apiPort: inferredPort, + apiProxyTarget: explicitTarget.replace(/\/$/, ''), + isLocalApiTarget: parsedTarget.protocol === 'http:' && isLoopbackHostname(parsedTarget.hostname) + } + } catch { + return { + apiPort: DEFAULT_API_PORT, + apiProxyTarget: `http://localhost:${DEFAULT_API_PORT}`, + isLocalApiTarget: true + } + } + } + + const configuredPort = normalizePort( + launcherEnv.API_PORT?.trim() || launcherEnv.VITE_API_PORT?.trim(), + DEFAULT_API_PORT + ) + + return { + apiPort: configuredPort, + apiProxyTarget: `http://localhost:${configuredPort}`, + isLocalApiTarget: true + } +} + +function describeCommandOwner(commandOwner) { + if (!commandOwner) { + return 'unknown process' + } + + const ownerWorkspace = commandOwner.workspacePath ? ` in ${commandOwner.workspacePath}` : '' + return `${commandOwner.command} (pid ${commandOwner.pid})${ownerWorkspace}` +} + +async function checkApiHealth(apiProxyTarget) { + const controller = new AbortController() + const timeoutId = setTimeout(() => controller.abort(), API_HEALTHCHECK_TIMEOUT_MS) + + try { + const response = await fetch(`${apiProxyTarget}${API_HEALTHCHECK_PATH}`, { signal: controller.signal }) + const payload = await response.json().catch(() => undefined) + return { + ok: response.status === 400 && payload?.error === API_HEALTHCHECK_EXPECTED_ERROR, + status: response.status + } + } catch (error) { + return { + ok: false, + error + } + } finally { + clearTimeout(timeoutId) + } +} + +function runCommand(command, args) { + const result = spawnSync(command, args, { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'] + }) + + if (result.error) { + return undefined + } + + return result +} + +function inspectPortOwnerWithLsof(port) { + const inspectionResult = runCommand('lsof', ['-nP', `-iTCP:${port}`, '-sTCP:LISTEN', '-Fpct']) + + if (!inspectionResult || inspectionResult.status !== 0) { + return undefined + } + + const lines = inspectionResult.stdout + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) + const pid = Number(lines.find((line) => line.startsWith('p'))?.slice(1)) + const command = lines.find((line) => line.startsWith('c'))?.slice(1) + + if (!pid || !command) { + return undefined + } + + return { + pid, + command, + workspacePath: readWorkspacePathForPid(pid) + } +} + +function inspectPortOwnerWithSs(port) { + const inspectionResult = runCommand('bash', ['-lc', `ss -ltnp '( sport = :${port} )' 2>/dev/null`]) + + if (!inspectionResult || inspectionResult.status !== 0) { + return undefined + } + + const inspectionText = inspectionResult.stdout.trim() + const match = inspectionText.match(/users:\(\("([^"]+)",pid=(\d+)/) + + if (!match) { + return undefined + } + + const pid = Number(match[2]) + return { + pid, + command: match[1], + workspacePath: readWorkspacePathForPid(pid) + } +} + +function inspectPortOwner(port) { + return inspectPortOwnerWithLsof(port) || inspectPortOwnerWithSs(port) +} + +function getApiChangeEntries(changePaths) { + const gitStatus = runCommand('git', ['status', '--short', '--untracked-files=all', '--', ...changePaths]) + + if (!gitStatus || gitStatus.status !== 0) { + return [] + } + + return gitStatus.stdout + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) +} + +export async function isPortAvailable(port) { + return new Promise((resolve) => { + const probe = createServer() + + probe.once('error', (error) => { + if (error && typeof error === 'object' && 'code' in error) { + resolve(!['EADDRINUSE', 'EACCES'].includes(error.code)) + return + } + + resolve(false) + }) + + probe.once('listening', () => { + probe.close(() => resolve(true)) + }) + + probe.listen({ port, exclusive: true }) + }) +} + +async function findNextAvailablePort(startPort) { + return (await isPortAvailable(startPort)) ? startPort : findNextAvailablePort(startPort + 1) +} + +export function resolveLauncherStrategy({ + defaultApiPort, + portAvailable, + healthyExistingApi, + ownerWorkspaceMatches, + hasApiChanges, + nextAvailablePort +}) { + if (portAvailable) { + return { + kind: 'start-new', + apiPort: defaultApiPort, + reuseExistingApi: false, + shouldPrompt: false + } + } + + if (healthyExistingApi && ownerWorkspaceMatches && !hasApiChanges) { + return { + kind: 'reuse-existing', + apiPort: defaultApiPort, + reuseExistingApi: true, + shouldPrompt: false + } + } + + return { + kind: 'prompt', + apiPort: nextAvailablePort, + recommendedPort: nextAvailablePort, + reuseExistingApi: false, + canReuseExistingApi: healthyExistingApi, + shouldPrompt: true + } +} + +async function promptForCustomPort(defaultPort) { + const rl = createInterface({ input, output }) + const answer = (await rl.question(`Custom API port [${defaultPort}]: `)).trim() + rl.close() + + const selectedPort = normalizePort(answer || String(defaultPort), 0) + + if (!selectedPort) { + output.write('Enter a valid positive port number.\n') + return promptForCustomPort(defaultPort) + } + + if (!(await isPortAvailable(selectedPort))) { + output.write(`Port ${selectedPort} is already in use.\n`) + return promptForCustomPort(defaultPort) + } + + return selectedPort +} + +async function promptForApiRuntime({ + defaultApiPort, + commandOwner, + healthyExistingApi, + apiChangeEntries, + recommendedPort, + canReuseExistingApi +}) { + output.write(`\nDefault API port ${defaultApiPort} is already in use.\n`) + output.write(`Owner: ${describeCommandOwner(commandOwner)}\n`) + output.write(`Health: ${healthyExistingApi ? 'Yearn API server detected' : 'Not the expected Yearn API response'}\n`) + + if (apiChangeEntries.length > 0) { + output.write(`API-related changes in this worktree:\n${apiChangeEntries.map((entry) => ` ${entry}`).join('\n')}\n`) + } + + const options = [ + { + key: '1', + label: `Start this workspace on ${recommendedPort} (Recommended)`, + value: { apiPort: recommendedPort, reuseExistingApi: false } + }, + canReuseExistingApi + ? { + key: '2', + label: `Reuse the existing API on ${defaultApiPort} and start only the client`, + value: { apiPort: defaultApiPort, reuseExistingApi: true } + } + : undefined, + { + key: canReuseExistingApi ? '3' : '2', + label: 'Choose a custom API port', + value: 'custom' + }, + { + key: canReuseExistingApi ? '4' : '3', + label: 'Cancel', + value: 'cancel' + } + ].filter(Boolean) + + output.write(`${options.map((option) => `${option.key}. ${option.label}`).join('\n')}\n`) + + const rl = createInterface({ input, output }) + const answer = (await rl.question('Choice [1]: ')).trim() || '1' + rl.close() + + const selectedOption = options.find((option) => option.key === answer) + + if (!selectedOption) { + output.write('Invalid choice.\n') + return promptForApiRuntime({ + defaultApiPort, + commandOwner, + healthyExistingApi, + apiChangeEntries, + recommendedPort, + canReuseExistingApi + }) + } + + if (selectedOption.value === 'cancel') { + exit(1) + } + + if (selectedOption.value === 'custom') { + const customPort = await promptForCustomPort(recommendedPort) + return { apiPort: customPort, reuseExistingApi: false } + } + + return selectedOption.value +} + +export function buildSessionEnv({ apiPort, apiProxyTarget, isLocalApiTarget, launcherEnv }) { + const sessionEnv = { + ...launcherEnv, + API_PROXY_TARGET: apiProxyTarget + } + + if (isLocalApiTarget) { + sessionEnv.API_PORT = String(apiPort) + } else { + delete sessionEnv.API_PORT + } + + return sessionEnv +} + +function killChild(child) { + try { + child.kill('SIGTERM') + } catch { + return undefined + } + + return undefined +} + +export async function runLauncherProcesses({ + apiPort, + apiProxyTarget, + isLocalApiTarget, + reuseExistingApi, + launcherEnv, + runtimeMetadata, + serverCommand, + clientCommand +}) { + const sessionEnv = buildSessionEnv({ + apiPort, + apiProxyTarget, + isLocalApiTarget, + launcherEnv + }) + const children = [ + reuseExistingApi + ? undefined + : (() => { + const serverChild = Bun.spawn(serverCommand, { + env: sessionEnv, + stdin: 'inherit', + stdout: 'inherit', + stderr: 'inherit' + }) + + if (runtimeMetadata) { + void resolveLocalApiRuntimeOwner({ apiPort, apiProxyTarget }).then((runtimeOwner) => { + if (!runtimeOwner) { + return + } + + writeApiRuntimeState({ + workspacePath: runtimeMetadata.workspacePath, + port: apiPort, + pid: runtimeOwner.pid, + mode: runtimeMetadata.mode, + head: runtimeMetadata.head, + startedAtMs: runtimeOwner.startedAtMs + }) + }) + } + + return serverChild + })(), + Bun.spawn(clientCommand, { + env: sessionEnv, + stdin: 'inherit', + stdout: 'inherit', + stderr: 'inherit' + }) + ].filter(Boolean) + + const stopChildren = () => children.map(killChild) + const handleInterrupt = () => { + stopChildren() + exit(0) + } + + process.once('SIGINT', handleInterrupt) + process.once('SIGTERM', handleInterrupt) + + const firstExit = await Promise.race(children.map((child) => child.exited.then((code) => ({ child, code })))) + children.filter((child) => child !== firstExit.child).map(killChild) + + exit(firstExit.code ?? 0) +} + +export async function chooseApiRuntime({ changePaths, mode = 'development' } = {}) { + const launcherEnv = resolveLauncherEnv(mode) + const configuredApiRuntime = resolveConfiguredApiRuntime(launcherEnv) + const { apiPort: defaultApiPort, apiProxyTarget, isLocalApiTarget } = configuredApiRuntime + const currentHead = getCurrentGitHead() + const trackedChangePaths = changePaths || getApiChangePathsForMode(mode) + + if (!isLocalApiTarget) { + return { + apiPort: defaultApiPort, + apiProxyTarget, + isLocalApiTarget, + reuseExistingApi: true, + launcherEnv + } + } + + const currentWorkspacePath = resolveRealPath(cwd()) + const portAvailable = await isPortAvailable(defaultApiPort) + const commandOwner = portAvailable ? undefined : inspectPortOwner(defaultApiPort) + const processStartedAtMs = commandOwner ? readProcessStartedAtMs(commandOwner.pid) : undefined + const ownerWorkspaceMatches = commandOwner?.workspacePath === currentWorkspacePath + const healthyExistingApi = portAvailable ? false : (await checkApiHealth(apiProxyTarget)).ok + const trackedApiChangeEntries = getApiChangeEntries(trackedChangePaths) + const runtimeEnvChangeEntries = ownerWorkspaceMatches ? getEnvChangeEntriesSince(mode, processStartedAtMs) : [] + const runtimeMismatchEntries = + ownerWorkspaceMatches && healthyExistingApi + ? getRecordedApiRuntimeMismatchEntries({ + workspacePath: currentWorkspacePath, + port: defaultApiPort, + pid: commandOwner.pid, + mode, + processStartedAtMs + }) + : [] + const committedApiChangeEntries = + ownerWorkspaceMatches && healthyExistingApi && runtimeMismatchEntries.length === 0 + ? getRecordedApiCommittedChangeEntries({ + workspacePath: currentWorkspacePath, + port: defaultApiPort, + currentHead, + changePaths: trackedChangePaths + }) + : [] + const apiChangeEntries = Array.from( + new Set([ + ...trackedApiChangeEntries, + ...runtimeEnvChangeEntries, + ...runtimeMismatchEntries, + ...committedApiChangeEntries + ]) + ) + const strategy = resolveLauncherStrategy({ + defaultApiPort, + portAvailable, + healthyExistingApi, + ownerWorkspaceMatches, + hasApiChanges: apiChangeEntries.length > 0, + nextAvailablePort: portAvailable ? defaultApiPort : await findNextAvailablePort(defaultApiPort + 1) + }) + + if (!strategy.shouldPrompt) { + return { + apiPort: strategy.apiPort, + apiProxyTarget: strategy.reuseExistingApi + ? apiProxyTarget + : getLocalApiProxyTarget(strategy.apiPort, apiProxyTarget), + isLocalApiTarget, + reuseExistingApi: strategy.reuseExistingApi, + launcherEnv, + runtimeMetadata: strategy.reuseExistingApi + ? undefined + : { + workspacePath: currentWorkspacePath, + mode, + head: currentHead + } + } + } + + if (!input.isTTY || !output.isTTY) { + output.write( + `API port ${defaultApiPort} is busy${apiChangeEntries.length > 0 ? ' and this worktree has API-related changes' : ''}. Using ${strategy.recommendedPort} instead.\n` + ) + + return { + apiPort: strategy.recommendedPort, + apiProxyTarget: getLocalApiProxyTarget(strategy.recommendedPort, apiProxyTarget), + isLocalApiTarget, + reuseExistingApi: false, + launcherEnv, + runtimeMetadata: { + workspacePath: currentWorkspacePath, + mode, + head: currentHead + } + } + } + + const selection = await promptForApiRuntime({ + defaultApiPort, + commandOwner, + healthyExistingApi, + apiChangeEntries, + recommendedPort: strategy.recommendedPort, + canReuseExistingApi: strategy.canReuseExistingApi + }) + + return { + ...selection, + apiProxyTarget: selection.reuseExistingApi + ? apiProxyTarget + : getLocalApiProxyTarget(selection.apiPort, apiProxyTarget), + isLocalApiTarget, + launcherEnv, + runtimeMetadata: selection.reuseExistingApi + ? undefined + : { + workspacePath: currentWorkspacePath, + mode, + head: currentHead + } + } +} diff --git a/scripts/api-runtime.test.ts b/scripts/api-runtime.test.ts new file mode 100644 index 000000000..9964ed9d7 --- /dev/null +++ b/scripts/api-runtime.test.ts @@ -0,0 +1,332 @@ +import { createHash } from 'node:crypto' +import { mkdirSync, mkdtempSync, rmSync, utimesSync, writeFileSync } from 'node:fs' +import { createServer } from 'node:net' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + buildSessionEnv, + DEFAULT_API_CHANGE_PATHS, + DEFAULT_API_ENV_CHANGE_PATHS, + getApiChangePathsForMode, + getEnvChangeEntriesSince, + getRecordedApiCommittedChangeEntries, + getRecordedApiRuntimeMismatchEntries, + isPortAvailable, + resolveConfiguredApiRuntime, + resolveLauncherEnv, + resolveLocalApiRuntimeOwner +} from './api-runtime.mjs' + +const openServers = new Set>() +const tempDirs = new Set() +const tempFiles = new Set() + +afterEach(async () => { + await Promise.all( + [...openServers].map( + (server) => + new Promise((resolve) => { + server.close(() => resolve(undefined)) + }) + ) + ) + openServers.clear() + tempDirs.forEach((dir) => { + rmSync(dir, { recursive: true, force: true }) + }) + tempDirs.clear() + tempFiles.forEach((file) => { + rmSync(file, { force: true }) + }) + tempFiles.clear() +}) + +describe('resolveConfiguredApiRuntime', () => { + it('treats non-loopback API proxy targets as external APIs', () => { + expect(resolveConfiguredApiRuntime({ API_PROXY_TARGET: 'https://staging.example.internal' })).toEqual({ + apiPort: 443, + apiProxyTarget: 'https://staging.example.internal', + isLocalApiTarget: false + }) + }) +}) + +describe('buildSessionEnv', () => { + it('preserves explicit external API proxy targets without forcing a local API port', () => { + expect( + buildSessionEnv({ + apiPort: 443, + apiProxyTarget: 'https://staging.example.internal', + isLocalApiTarget: false, + launcherEnv: { + API_PORT: '3001', + API_PROXY_TARGET: 'https://staging.example.internal' + } + }) + ).toEqual({ + API_PROXY_TARGET: 'https://staging.example.internal' + }) + }) +}) + +describe('resolveLauncherEnv', () => { + it('uses Vite env semantics, including mode-specific files and variable expansion', () => { + const envDir = mkdtempSync(join(tmpdir(), 'yearn-api-runtime-')) + tempDirs.add(envDir) + const apiPortPlaceholder = '${' + 'API_PORT}' + + writeFileSync(join(envDir, '.env'), `API_PORT=3001\nVITE_API_PROXY_TARGET=http://localhost:${apiPortPlaceholder}\n`) + writeFileSync(join(envDir, '.env.local'), 'VITE_API_PROXY_TARGET=http://localhost:3002\n') + writeFileSync(join(envDir, '.env.production'), 'API_PORT=3003\n') + writeFileSync( + join(envDir, '.env.production.local'), + `API_PORT=3007\nVITE_API_PROXY_TARGET=http://localhost:${apiPortPlaceholder}\n` + ) + + expect(resolveLauncherEnv('production', { envDir, shellEnv: {} }).VITE_API_PROXY_TARGET).toBe( + 'http://localhost:3007' + ) + }) +}) + +describe('getApiChangePathsForMode', () => { + it('includes only development env files for development launcher reuse detection', () => { + expect(getApiChangePathsForMode('development')).toEqual( + expect.arrayContaining([ + ...DEFAULT_API_CHANGE_PATHS, + ...DEFAULT_API_ENV_CHANGE_PATHS, + '.env.development', + '.env.development.local' + ]) + ) + expect(getApiChangePathsForMode('development')).not.toEqual( + expect.arrayContaining(['.env.production', '.env.production.local']) + ) + }) + + it('includes only production env files for production launcher reuse detection', () => { + expect(getApiChangePathsForMode('production')).toEqual( + expect.arrayContaining([ + ...DEFAULT_API_CHANGE_PATHS, + ...DEFAULT_API_ENV_CHANGE_PATHS, + '.env.production', + '.env.production.local' + ]) + ) + expect(getApiChangePathsForMode('production')).not.toEqual( + expect.arrayContaining(['.env.development', '.env.development.local']) + ) + }) +}) + +describe('getEnvChangeEntriesSince', () => { + it('flags loaded env files that are newer than the running API process', () => { + const envDir = mkdtempSync(join(tmpdir(), 'yearn-api-runtime-')) + tempDirs.add(envDir) + const processStartedAtMs = new Date('2026-04-10T17:00:00.000Z').getTime() + const olderEnvPath = join(envDir, '.env') + const newerEnvPath = join(envDir, '.env.development.local') + + writeFileSync(olderEnvPath, 'API_PORT=3001\n') + writeFileSync(newerEnvPath, 'API_PORT=3007\n') + utimesSync(olderEnvPath, new Date(processStartedAtMs - 60_000), new Date(processStartedAtMs - 60_000)) + utimesSync(newerEnvPath, new Date(processStartedAtMs + 60_000), new Date(processStartedAtMs + 60_000)) + + expect(getEnvChangeEntriesSince('development', processStartedAtMs, envDir)).toEqual([ + 'M .env.development.local (newer than the running API process)' + ]) + }) +}) + +describe('getRecordedApiRuntimeMismatchEntries', () => { + it('flags running APIs that were started in a different launcher mode', () => { + const workspacePath = mkdtempSync(join(tmpdir(), 'yearn-api-runtime-workspace-')) + tempDirs.add(workspacePath) + const port = 3001 + const statePath = join( + tmpdir(), + 'yearn-api-runtime', + `${createHash('sha1').update(workspacePath).digest('hex')}-${port}.json` + ) + tempFiles.add(statePath) + mkdirSync(join(tmpdir(), 'yearn-api-runtime'), { recursive: true }) + + writeFileSync( + statePath, + JSON.stringify({ + pid: 1234, + mode: 'production', + head: 'abc123', + startedAtMs: 1_710_000_000_000 + }) + ) + + expect( + getRecordedApiRuntimeMismatchEntries({ + workspacePath, + port, + pid: 1234, + mode: 'development', + processStartedAtMs: 1_710_000_000_000 + }) + ).toEqual(['M the running API was started in production mode, not development']) + }) + + it('does not treat a different git checkout as a runtime mismatch by itself', () => { + const workspacePath = mkdtempSync(join(tmpdir(), 'yearn-api-runtime-workspace-')) + tempDirs.add(workspacePath) + const port = 3001 + const statePath = join( + tmpdir(), + 'yearn-api-runtime', + `${createHash('sha1').update(workspacePath).digest('hex')}-${port}.json` + ) + tempFiles.add(statePath) + mkdirSync(join(tmpdir(), 'yearn-api-runtime'), { recursive: true }) + + writeFileSync( + statePath, + JSON.stringify({ + pid: 1234, + mode: 'development', + head: 'old-head', + startedAtMs: 1_710_000_000_000 + }) + ) + + expect( + getRecordedApiRuntimeMismatchEntries({ + workspacePath, + port, + pid: 1234, + mode: 'development', + processStartedAtMs: 1_710_000_000_000 + }) + ).toEqual([]) + }) +}) + +describe('getRecordedApiCommittedChangeEntries', () => { + it('ignores HEAD changes when API-tracked paths are unchanged', () => { + const workspacePath = mkdtempSync(join(tmpdir(), 'yearn-api-runtime-workspace-')) + tempDirs.add(workspacePath) + const port = 3001 + const statePath = join( + tmpdir(), + 'yearn-api-runtime', + `${createHash('sha1').update(workspacePath).digest('hex')}-${port}.json` + ) + tempFiles.add(statePath) + mkdirSync(join(tmpdir(), 'yearn-api-runtime'), { recursive: true }) + + writeFileSync( + statePath, + JSON.stringify({ + pid: 1234, + mode: 'development', + head: 'old-head', + startedAtMs: 1_710_000_000_000 + }) + ) + + expect( + getRecordedApiCommittedChangeEntries({ + workspacePath, + port, + currentHead: 'new-head', + changePaths: ['api', '.env.development'], + runCommandImpl: () => ({ + pid: 1, + status: 0, + stdout: '', + stderr: '', + output: ['', '', ''], + signal: null + }) + }) + ).toEqual([]) + }) + + it('flags committed API-path changes since the running API started', () => { + const workspacePath = mkdtempSync(join(tmpdir(), 'yearn-api-runtime-workspace-')) + tempDirs.add(workspacePath) + const port = 3001 + const statePath = join( + tmpdir(), + 'yearn-api-runtime', + `${createHash('sha1').update(workspacePath).digest('hex')}-${port}.json` + ) + tempFiles.add(statePath) + mkdirSync(join(tmpdir(), 'yearn-api-runtime'), { recursive: true }) + + writeFileSync( + statePath, + JSON.stringify({ + pid: 1234, + mode: 'development', + head: 'old-head', + startedAtMs: 1_710_000_000_000 + }) + ) + + expect( + getRecordedApiCommittedChangeEntries({ + workspacePath, + port, + currentHead: 'new-head', + changePaths: ['api', '.env.development'], + runCommandImpl: () => ({ + pid: 1, + status: 0, + stdout: 'M\tapi/server.ts\n', + stderr: '', + output: ['', '', ''], + signal: null + }) + }) + ).toEqual(['M\tapi/server.ts']) + }) +}) + +describe('resolveLocalApiRuntimeOwner', () => { + it('records the actual API listener pid instead of the wrapper pid', async () => { + const healthChecks = [ + { ok: false, error: new Error('not ready') }, + { ok: true, status: 400 } + ] + + await expect( + resolveLocalApiRuntimeOwner({ + apiPort: 3001, + apiProxyTarget: 'http://localhost:3001', + checkApiHealthImpl: async () => healthChecks.shift() || { ok: true, status: 400 }, + inspectPortOwnerImpl: () => ({ pid: 4321, command: 'bun', workspacePath: '/tmp/yearn-fi' }), + readProcessStartedAtMsImpl: () => 1_710_000_000_000, + pollIntervalMs: 0 + }) + ).resolves.toEqual({ + pid: 4321, + startedAtMs: 1_710_000_000_000 + }) + }) +}) + +describe('isPortAvailable', () => { + it('detects ports already claimed by another listener without relying on ss', async () => { + const server = createServer() + openServers.add(server) + + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(0, () => resolve(undefined)) + }) + + const address = server.address() + if (!address || typeof address === 'string') { + throw new Error('Expected a TCP server address') + } + + expect(await isPortAvailable(address.port)).toBe(false) + }) +}) diff --git a/scripts/dev.mjs b/scripts/dev.mjs new file mode 100644 index 000000000..a71210aba --- /dev/null +++ b/scripts/dev.mjs @@ -0,0 +1,34 @@ +import { exit, stdout as output } from 'node:process' +import { + buildSessionEnv, + chooseApiRuntime, + getApiChangePathsForMode, + resolveLauncherStrategy, + runLauncherProcesses +} from './api-runtime.mjs' + +const DEV_API_CHANGE_PATHS = [...getApiChangePathsForMode('development'), 'scripts/dev.mjs'] + +export const resolveDevLauncherStrategy = resolveLauncherStrategy + +async function main() { + const selection = await chooseApiRuntime({ changePaths: DEV_API_CHANGE_PATHS, mode: 'development' }) + const sessionEnv = buildSessionEnv(selection) + + output.write( + `${selection.reuseExistingApi ? 'Reusing' : 'Starting'} API ${selection.reuseExistingApi ? 'at' : 'on'} ${sessionEnv.API_PROXY_TARGET}\n` + ) + + await runLauncherProcesses({ + ...selection, + serverCommand: ['bun', 'run', 'dev:server'], + clientCommand: ['bun', 'run', 'dev:client'] + }) +} + +if (typeof Bun !== 'undefined' && import.meta.path === Bun.main) { + void main().catch((error) => { + console.error('Failed to start dev environment', error) + exit(1) + }) +} diff --git a/scripts/dev.test.ts b/scripts/dev.test.ts new file mode 100644 index 000000000..d5d0e5a72 --- /dev/null +++ b/scripts/dev.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from 'vitest' +import { resolveDevLauncherStrategy } from './dev.mjs' + +describe('resolveDevLauncherStrategy', () => { + it('starts a new API server on the default port when the port is free', () => { + expect( + resolveDevLauncherStrategy({ + defaultApiPort: 3001, + portAvailable: true, + healthyExistingApi: false, + ownerWorkspaceMatches: false, + hasApiChanges: false, + nextAvailablePort: 3002 + }) + ).toEqual({ + kind: 'start-new', + apiPort: 3001, + reuseExistingApi: false, + shouldPrompt: false + }) + }) + + it('reuses the existing API server when it belongs to this workspace and there are no API changes', () => { + expect( + resolveDevLauncherStrategy({ + defaultApiPort: 3001, + portAvailable: false, + healthyExistingApi: true, + ownerWorkspaceMatches: true, + hasApiChanges: false, + nextAvailablePort: 3002 + }) + ).toEqual({ + kind: 'reuse-existing', + apiPort: 3001, + reuseExistingApi: true, + shouldPrompt: false + }) + }) + + it('prompts for a new port when the default port is occupied by another workspace', () => { + expect( + resolveDevLauncherStrategy({ + defaultApiPort: 3001, + portAvailable: false, + healthyExistingApi: true, + ownerWorkspaceMatches: false, + hasApiChanges: false, + nextAvailablePort: 3002 + }) + ).toEqual({ + kind: 'prompt', + apiPort: 3002, + recommendedPort: 3002, + reuseExistingApi: false, + canReuseExistingApi: true, + shouldPrompt: true + }) + }) + + it('prompts for a new port when this workspace has API-related changes', () => { + expect( + resolveDevLauncherStrategy({ + defaultApiPort: 3001, + portAvailable: false, + healthyExistingApi: true, + ownerWorkspaceMatches: true, + hasApiChanges: true, + nextAvailablePort: 3002 + }) + ).toEqual({ + kind: 'prompt', + apiPort: 3002, + recommendedPort: 3002, + reuseExistingApi: false, + canReuseExistingApi: true, + shouldPrompt: true + }) + }) +}) diff --git a/scripts/ensure-api-server.js b/scripts/ensure-api-server.js index 5c2420a21..94082de04 100644 --- a/scripts/ensure-api-server.js +++ b/scripts/ensure-api-server.js @@ -1,23 +1,28 @@ -const DEFAULT_API_SERVER_PORT = '3001' +const DEFAULT_API_PORT = '3001' const API_PROXY_TARGET = - process.env.API_PROXY_TARGET || `http://localhost:${process.env.API_SERVER_PORT || DEFAULT_API_SERVER_PORT}` + process.env.API_PROXY_TARGET || + `http://localhost:${process.env.API_PORT || process.env.API_SERVER_PORT || DEFAULT_API_PORT}` const API_PROXY_TARGET_DESCRIPTION = process.env.API_PROXY_TARGET?.trim() ? 'the configured API proxy target' : 'the default local API address' -function resolveApiServerPort() { - if (process.env.API_SERVER_PORT) { - return process.env.API_SERVER_PORT - } - - return DEFAULT_API_SERVER_PORT -} - -const API_SERVER_PORT = resolveApiServerPort() const API_HEALTHCHECK_PATH = process.env.API_HEALTHCHECK_PATH || '/api/enso/balances' const API_HEALTHCHECK_EXPECTED_ERROR = 'Missing eoaAddress' const API_HEALTHCHECK_TIMEOUT_MS = Number(process.env.API_HEALTHCHECK_TIMEOUT_MS || '500') +function resolveApiPort() { + if (process.env.API_PORT) { + return process.env.API_PORT + } + + try { + const parsedTarget = new URL(API_PROXY_TARGET) + return parsedTarget.port || (parsedTarget.protocol === 'https:' ? '443' : '80') + } catch { + return '3001' + } +} + async function checkApi() { const controller = new AbortController() const timeout = setTimeout(() => controller.abort(), API_HEALTHCHECK_TIMEOUT_MS) @@ -54,7 +59,7 @@ async function checkApi() { const child = Bun.spawn(['bun', 'api/server.ts'], { env: { ...process.env, - API_SERVER_PORT + API_PORT: resolveApiPort() }, stdin: 'inherit', stdout: 'inherit', diff --git a/scripts/preview.mjs b/scripts/preview.mjs new file mode 100644 index 000000000..37e14f378 --- /dev/null +++ b/scripts/preview.mjs @@ -0,0 +1,34 @@ +import { exit, stdout as output } from 'node:process' +import { + buildSessionEnv, + chooseApiRuntime, + getApiChangePathsForMode, + resolveLauncherStrategy, + runLauncherProcesses +} from './api-runtime.mjs' + +const PREVIEW_API_CHANGE_PATHS = [...getApiChangePathsForMode('production'), 'scripts/preview.mjs'] + +export const resolvePreviewLauncherStrategy = resolveLauncherStrategy + +async function main() { + const selection = await chooseApiRuntime({ changePaths: PREVIEW_API_CHANGE_PATHS, mode: 'production' }) + const sessionEnv = buildSessionEnv(selection) + + output.write( + `${selection.reuseExistingApi ? 'Reusing' : 'Starting'} API ${selection.reuseExistingApi ? 'at' : 'on'} ${sessionEnv.API_PROXY_TARGET}\n` + ) + + await runLauncherProcesses({ + ...selection, + serverCommand: ['bun', 'run', 'preview:server'], + clientCommand: ['bun', 'run', 'preview:client'] + }) +} + +if (typeof Bun !== 'undefined' && import.meta.path === Bun.main) { + void main().catch((error) => { + console.error('Failed to start preview environment', error) + exit(1) + }) +} diff --git a/scripts/preview.test.ts b/scripts/preview.test.ts new file mode 100644 index 000000000..c33e61f04 --- /dev/null +++ b/scripts/preview.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest' +import { resolvePreviewLauncherStrategy } from './preview.mjs' + +describe('resolvePreviewLauncherStrategy', () => { + it('reuses the existing API server when it belongs to this workspace and there are no API changes', () => { + expect( + resolvePreviewLauncherStrategy({ + defaultApiPort: 3001, + portAvailable: false, + healthyExistingApi: true, + ownerWorkspaceMatches: true, + hasApiChanges: false, + nextAvailablePort: 3002 + }) + ).toEqual({ + kind: 'reuse-existing', + apiPort: 3001, + reuseExistingApi: true, + shouldPrompt: false + }) + }) + + it('prompts for a new port when this workspace has API-related changes', () => { + expect( + resolvePreviewLauncherStrategy({ + defaultApiPort: 3001, + portAvailable: false, + healthyExistingApi: true, + ownerWorkspaceMatches: true, + hasApiChanges: true, + nextAvailablePort: 3002 + }) + ).toEqual({ + kind: 'prompt', + apiPort: 3002, + recommendedPort: 3002, + reuseExistingApi: false, + canReuseExistingApi: true, + shouldPrompt: true + }) + }) +}) diff --git a/scripts/tenderly-vnet-status.test.ts b/scripts/tenderly-vnet-status.test.ts index dbb2ddf2e..5596cc800 100644 --- a/scripts/tenderly-vnet-status.test.ts +++ b/scripts/tenderly-vnet-status.test.ts @@ -10,6 +10,8 @@ import { selectMatchingTenderlyVnet } from './tenderly-vnet-status' +const TEST_TX_FROM_ADDRESS = process.env.TENDERLY_TEST_TX_FROM_ADDRESS ?? '0x2222222222222222222222222222222222222222' + describe('tenderly-vnet-status', () => { it('defaults to the webops profile', () => { expect( @@ -137,7 +139,7 @@ describe('tenderly-vnet-status', () => { createdAtAgeLabel: '8s', blockNumber: 24_735_515, txHash: '0xb00dc057e50c8926896495cb31717bfa7ab47608673789b4b7b478ec114080cb', - from: '0x5b0d3243c78fb9d4ac035fb2384ffdf7a9ef6396', + from: TEST_TX_FROM_ADDRESS, to: '0xc56413869c6cdf96496f2b1ef801fedbdfa7ddb0' } ], diff --git a/src/components/pages/icon-list/index.tsx b/src/components/pages/icon-list/index.tsx index 162ee6296..243a3d927 100644 --- a/src/components/pages/icon-list/index.tsx +++ b/src/components/pages/icon-list/index.tsx @@ -31,7 +31,7 @@ const ICON_MODULES = { ...import.meta.glob('/src/components/pages/**/Icons.tsx', { eager: true }) } -const SOURCE_MODULES = import.meta.glob('/src/**/*.{ts,tsx,css}', { as: 'raw' }) +const SOURCE_MODULES = import.meta.glob('/src/**/*.{ts,tsx,css}', { query: '?raw', import: 'default' }) const BROKEN_ASSET_NOTES: Record = { 'public/yearn-logo.svg': 'Broken: file is empty.', diff --git a/src/components/pages/portfolio/claimRewards.helpers.test.ts b/src/components/pages/portfolio/claimRewards.helpers.test.ts new file mode 100644 index 000000000..d6f8d08c5 --- /dev/null +++ b/src/components/pages/portfolio/claimRewards.helpers.test.ts @@ -0,0 +1,16 @@ +import type { TGroupedMerkleReward } from '@pages/vaults/components/widget/rewards/types' +import { describe, expect, it, vi } from 'vitest' +import { mergeChainMerkleData } from './claimRewards.helpers' + +describe('claimRewards helpers', () => { + it('tracks initial empty loading states so the portfolio rewards view stays in loading mode', () => { + const refetch = vi.fn() + const next = mergeChainMerkleData({}, 747474, [] as TGroupedMerkleReward[], true, refetch) + + expect(next[747474]).toMatchObject({ + rewards: [], + isLoading: true, + refetch + }) + }) +}) diff --git a/src/components/pages/portfolio/claimRewards.helpers.ts b/src/components/pages/portfolio/claimRewards.helpers.ts new file mode 100644 index 000000000..7debfe394 --- /dev/null +++ b/src/components/pages/portfolio/claimRewards.helpers.ts @@ -0,0 +1,34 @@ +import type { TGroupedMerkleReward } from '@pages/vaults/components/widget/rewards/types' + +type TChainMerkleData = Record< + number, + { + rewards: TGroupedMerkleReward[] + isLoading: boolean + refetch: () => void + } +> + +function merkleRewardsEqual(a: TGroupedMerkleReward[], b: TGroupedMerkleReward[]): boolean { + if (a.length !== b.length) return false + return a.every((reward, index) => { + const other = b[index] + return reward.token.address === other?.token.address && reward.totalUnclaimed === other?.totalUnclaimed + }) +} + +export function mergeChainMerkleData( + prev: TChainMerkleData, + chainId: number, + rewards: TGroupedMerkleReward[], + isLoading: boolean, + refetch: () => void +): TChainMerkleData { + const existing = prev[chainId] + + if (existing?.isLoading === isLoading && merkleRewardsEqual(existing.rewards, rewards)) { + return prev + } + + return { ...prev, [chainId]: { rewards, isLoading, refetch } } +} diff --git a/src/components/pages/portfolio/components/EmptySectionCard.tsx b/src/components/pages/portfolio/components/EmptySectionCard.tsx index c00780dcd..d390e9b16 100644 --- a/src/components/pages/portfolio/components/EmptySectionCard.tsx +++ b/src/components/pages/portfolio/components/EmptySectionCard.tsx @@ -5,26 +5,54 @@ import { Link } from 'react-router' type TEmptySectionCardProps = { title: string - description: string + description?: string ctaLabel: string + ctaClassName?: string + secondaryCtaClassName?: string + secondaryCtaHref?: string + secondaryCtaLabel?: string } & ({ onClick: () => void; href?: never } | { href: string; onClick?: never }) export function EmptySectionCard({ title, description, ctaLabel, + ctaClassName, + secondaryCtaClassName, + secondaryCtaHref, + secondaryCtaLabel, onClick, href }: TEmptySectionCardProps): ReactElement { const actionButton = href ? ( - + {ctaLabel} ) : ( - ) + const secondaryActionButton = + secondaryCtaHref && secondaryCtaLabel ? ( + + {secondaryCtaLabel} + + ) : null + const action = secondaryActionButton ? ( +
+ {actionButton} + {secondaryActionButton} +
+ ) : ( + actionButton + ) - return + return } diff --git a/src/components/pages/portfolio/components/PortfolioHistoryBreakdownModal.tsx b/src/components/pages/portfolio/components/PortfolioHistoryBreakdownModal.tsx new file mode 100644 index 000000000..54c36090f --- /dev/null +++ b/src/components/pages/portfolio/components/PortfolioHistoryBreakdownModal.tsx @@ -0,0 +1,332 @@ +import { Dialog, Transition, TransitionChild } from '@headlessui/react' +import { + getVaultChainID, + getVaultName, + getVaultSymbol, + getVaultToken, + type TKongVaultInput +} from '@pages/vaults/domain/kongVaultSelectors' +import { getVaultPrimaryLogoSrc } from '@pages/vaults/utils/vaultLogo' +import { TokenLogo } from '@shared/components/TokenLogo' +import { useYearn } from '@shared/contexts/useYearn' +import { IconClose } from '@shared/icons/IconClose' +import { IconSpinner } from '@shared/icons/IconSpinner' +import { cl, formatUSD, SUPPORTED_NETWORKS, toAddress } from '@shared/utils' +import type { ReactElement } from 'react' +import { Fragment, useEffect, useMemo, useState } from 'react' +import { Link } from 'react-router' +import { usePortfolioBreakdown } from '../hooks/usePortfolioBreakdown' +import type { TPortfolioBreakdownResponse, TPortfolioBreakdownVault } from '../types/api' + +type TPortfolioHistoryBreakdownModalProps = { + date: string | null + isOpen: boolean + onClose: () => void +} + +type TEnrichedBreakdownVault = { + chainId: number + chainName: string + vaultAddress: string + vaultHref: string + displayName: string + displaySymbol: string + logoSrc: string + altLogoSrc: string | undefined + usdValue: number + status: TPortfolioBreakdownVault['status'] +} + +type TBreakdownDisplayVault = { + chainId: number + vaultAddress: string + usdValue: number + status: TPortfolioBreakdownVault['status'] + metadata: TPortfolioBreakdownVault['metadata'] +} + +function formatBreakdownDate(date: string | null): string { + if (!date) { + return 'Selected date' + } + + const parsed = new Date(`${date}T00:00:00Z`) + if (Number.isNaN(parsed.getTime())) { + return date + } + + return parsed.toLocaleDateString('en-US', { + month: 'long', + day: 'numeric', + year: 'numeric', + timeZone: 'UTC' + }) +} + +function getStatusLabel(status: TPortfolioBreakdownVault['status']): string | null { + switch (status) { + case 'missing_metadata': + return 'Missing metadata' + case 'missing_pps': + return 'Missing PPS' + case 'missing_price': + return 'Missing price' + default: + return null + } +} + +function getChainName(chainId: number): string { + return SUPPORTED_NETWORKS.find((network) => network.id === chainId)?.name ?? `Chain ${chainId}` +} + +function getBreakdownDisplayVaults(vaults: TPortfolioBreakdownVault[]): TBreakdownDisplayVault[] { + return vaults.map((vault) => ({ + chainId: vault.chainId, + vaultAddress: toAddress(vault.vaultAddress), + usdValue: vault.usdValue ?? 0, + status: vault.status, + metadata: vault.metadata + })) +} + +export function PortfolioHistoryBreakdownModal({ + date, + isOpen, + onClose +}: TPortfolioHistoryBreakdownModalProps): ReactElement { + const { allVaults } = useYearn() + const { data, isLoading, error } = usePortfolioBreakdown(date, isOpen) + const [closingDataSnapshot, setClosingDataSnapshot] = useState<{ + date: string | null + data: TPortfolioBreakdownResponse + } | null>(null) + + useEffect(() => { + if (!isOpen || !data) { + return + } + + setClosingDataSnapshot({ date, data }) + }, [data, date, isOpen]) + + const resolvedData = !isOpen && closingDataSnapshot?.date === date ? closingDataSnapshot.data : data + + useEffect(() => { + if (!isOpen) { + return + } + + const root = document.documentElement + const body = document.body + const previousBodyOverflow = body.style.overflow + const previousBodyPaddingRight = body.style.paddingRight + const previousRootOverflow = root.style.getPropertyValue('overflow') + const previousRootOverflowPriority = root.style.getPropertyPriority('overflow') + const previousRootPaddingRight = root.style.getPropertyValue('padding-right') + const previousRootPaddingRightPriority = root.style.getPropertyPriority('padding-right') + const scrollBarWidth = window.innerWidth - root.clientWidth + + root.style.setProperty('overflow', 'hidden', 'important') + body.style.overflow = 'hidden' + if (scrollBarWidth > 0) { + body.style.paddingRight = `${scrollBarWidth}px` + root.style.setProperty('padding-right', `${scrollBarWidth}px`, 'important') + } + + return () => { + body.style.overflow = previousBodyOverflow + body.style.paddingRight = previousBodyPaddingRight + if (previousRootOverflow) { + root.style.setProperty('overflow', previousRootOverflow, previousRootOverflowPriority) + } else { + root.style.removeProperty('overflow') + } + if (previousRootPaddingRight) { + root.style.setProperty('padding-right', previousRootPaddingRight, previousRootPaddingRightPriority) + } else { + root.style.removeProperty('padding-right') + } + } + }, [isOpen]) + + const enrichedVaults = useMemo(() => { + const displayedVaults = getBreakdownDisplayVaults(resolvedData?.vaults ?? []) + + return displayedVaults + .map((vault): TEnrichedBreakdownVault => { + const normalizedVaultAddress = toAddress(vault.vaultAddress) + const currentVault = allVaults[normalizedVaultAddress] as TKongVaultInput | undefined + const fallbackTokenAddress = vault.metadata?.tokenAddress + const fallbackLogoSrc = fallbackTokenAddress + ? `${import.meta.env.VITE_BASE_YEARN_ASSETS_URI}/tokens/${vault.chainId}/${toAddress(fallbackTokenAddress).toLowerCase()}/logo-128.png` + : `${import.meta.env.VITE_BASE_YEARN_ASSETS_URI}/tokens/${vault.chainId}/${normalizedVaultAddress.toLowerCase()}/logo-128.png` + + return { + chainId: vault.chainId, + chainName: currentVault ? getChainName(getVaultChainID(currentVault)) : getChainName(vault.chainId), + vaultAddress: normalizedVaultAddress, + vaultHref: `/vaults/${vault.chainId}/${normalizedVaultAddress}`, + displayName: currentVault ? getVaultName(currentVault) : vault.metadata?.symbol || normalizedVaultAddress, + displaySymbol: currentVault ? getVaultSymbol(currentVault) : vault.metadata?.symbol || 'Unknown', + logoSrc: currentVault ? getVaultPrimaryLogoSrc(currentVault) : fallbackLogoSrc, + altLogoSrc: currentVault + ? `${import.meta.env.VITE_BASE_YEARN_ASSETS_URI}/tokens/${getVaultChainID(currentVault)}/${toAddress(getVaultToken(currentVault).address).toLowerCase()}/logo-128.png` + : undefined, + usdValue: vault.usdValue, + status: vault.status + } + }) + .sort((leftVault, rightVault) => rightVault.usdValue - leftVault.usdValue) + }, [allVaults, resolvedData?.vaults]) + + const title = `Vault breakdown on ${formatBreakdownDate(date)}` + const issueCount = enrichedVaults.filter((vault) => vault.status !== 'ok').length + + return ( + + + +
+ + +
+
+ + +
+
+ + {title} + + {resolvedData ? ( +

+ {`${enrichedVaults.length} vault${enrichedVaults.length === 1 ? '' : 's'} • ${formatUSD(resolvedData.summary.totalUsdValue, 2, 2)}`} +

+ ) : null} +
+ +
+ +
+ {isLoading ? ( +
+ +
+ ) : error ? ( +
+

{'Unable to load vault breakdown right now.'}

+
+ ) : resolvedData && enrichedVaults.length > 0 ? ( +
+ {issueCount > 0 ? ( +
+ {`${issueCount} valuation issue${issueCount === 1 ? '' : 's'} on this date. Rows with missing pricing inputs remain in the total list below with a status badge.`} +
+ ) : null} + {enrichedVaults.map((vault) => { + const statusLabel = getStatusLabel(vault.status) + return ( +
+ +
+ + {vault.displayName} + +
+ {vault.displaySymbol} + {'•'} + {vault.chainName} + {statusLabel ? ( + + {statusLabel} + + ) : null} +
+
+
+

+ {formatUSD(vault.usdValue, 2, 2)} +

+
+
+ ) + })} +
+ ) : ( +
+

+ {resolvedData?.message || 'No vault breakdown available for this date.'} +

+
+ )} +
+
+
+
+
+
+
+ ) +} diff --git a/src/components/pages/portfolio/components/PortfolioHistoryChart.tsx b/src/components/pages/portfolio/components/PortfolioHistoryChart.tsx new file mode 100644 index 000000000..938c37dea --- /dev/null +++ b/src/components/pages/portfolio/components/PortfolioHistoryChart.tsx @@ -0,0 +1,1145 @@ +import type { ChartConfig } from '@pages/vaults/components/detail/charts/ChartPrimitives' +import { ChartContainer, ChartTooltip } from '@pages/vaults/components/detail/charts/ChartPrimitives' +import { + CHART_WITH_AXES_MARGIN, + CHART_Y_AXIS_TICK_MARGIN, + CHART_Y_AXIS_TICK_STYLE, + CHART_Y_AXIS_WIDTH +} from '@pages/vaults/components/detail/charts/chartLayout' +import { getVaultAddress } from '@pages/vaults/domain/kongVaultSelectors' +import { + formatChartMonthYearLabel, + formatChartTooltipDate, + formatChartWeekLabel, + getChartMonthlyTicks, + getChartWeeklyTicks, + getTimeframeLimit +} from '@pages/vaults/utils/charts' +import { YearnLogoSpinner } from '@shared/components/YearnLogoSpinner' +import { useWeb3 } from '@shared/contexts/useWeb3' +import { useYearn } from '@shared/contexts/useYearn' +import { IconChevron } from '@shared/icons/IconChevron' +import { cl, formatPercent, formatUSD, SELECTOR_BAR_STYLES } from '@shared/utils' +import { getVaultName as getDisplayVaultName } from '@shared/utils/helpers' +import type { ReactElement } from 'react' +import { useEffect, useId, useMemo, useState } from 'react' +import { Link } from 'react-router' +import { Area, CartesianGrid, ComposedChart, Line, XAxis, YAxis } from 'recharts' +import type { AxisDomain } from 'recharts/types/util/types' +import type { + TPortfolioHistoryChartData, + TPortfolioHistoryDenomination, + TPortfolioProtocolReturnHistoryChartData, + TPortfolioProtocolReturnHistoryFamilySeries, + TPortfolioProtocolReturnHistorySummary +} from '../types/api' +import { PortfolioHistoryBreakdownModal } from './PortfolioHistoryBreakdownModal' +import type { TPortfolioVaultGrowthChartMode, TPortfolioVaultGrowthChartSeries } from './PortfolioVaultGrowthChart' +import { PortfolioVaultGrowthChart } from './PortfolioVaultGrowthChart' + +export type TPortfolioHistoryChartTimeframe = '30d' | '90d' | '1y' | 'all' +export type TPortfolioHistoryChartTab = 'balance' | 'growth' | 'annualized' | 'index' +export type TGrowthDisplayMode = 'index' | 'usd' | 'eth' +type TPortfolioHistoryValueType = TGrowthDisplayMode | TPortfolioVaultGrowthChartMode + +type TPortfolioHistoryChartProps = { + balanceData: TPortfolioHistoryChartData | null + protocolReturnData: TPortfolioProtocolReturnHistoryChartData | null + protocolReturnSummary: TPortfolioProtocolReturnHistorySummary | null + protocolReturnFamilySeries: TPortfolioProtocolReturnHistoryFamilySeries + denomination: TPortfolioHistoryDenomination + timeframe: TPortfolioHistoryChartTimeframe + activeTab: TPortfolioHistoryChartTab + growthDisplayModeOverride: TGrowthDisplayMode | null + onGrowthDisplayModeOverrideChange: (mode: TGrowthDisplayMode | null) => void + vaultGrowthMode: TPortfolioVaultGrowthChartMode + onVaultGrowthModeChange: (mode: TPortfolioVaultGrowthChartMode) => void + balanceIsLoading: boolean + balanceIsEmpty?: boolean + balanceError?: Error | null + protocolReturnIsLoading: boolean + protocolReturnIsEmpty?: boolean + protocolReturnError?: Error | null + embedded?: boolean + reserveControlSpace?: boolean + loadingProgress?: { + progress: number + message: string + detail: string | null + } | null + className?: string +} + +type TChartPoint = { + date: string + value: number | null + isLive?: boolean +} + +type TPortfolioHistoryTooltipProps = { + active?: boolean + payload?: Array<{ + value?: unknown + payload?: { + date?: string + value?: unknown + isLive?: boolean + [key: string]: unknown + } + }> +} + +type TActiveChartState = { + activeLabel?: string | number +} + +const NON_NEGATIVE_AUTO_DOMAIN: AxisDomain = [ + (dataMin: number) => (Number.isFinite(dataMin) && dataMin < 0 ? dataMin : 0), + (dataMax: number) => (Number.isFinite(dataMax) ? dataMax : 0) +] +const EVEN_Y_AXIS_TICK_COUNT = 5 +const Y_AXIS_ZERO_EPSILON = 1e-9 +const Y_AXIS_HEADROOM_MULTIPLIER = 1.05 + +function getNiceCeiling(value: number, intervals: number): number { + const roughStep = value / intervals + const magnitude = 10 ** Math.floor(Math.log10(roughStep)) + const normalizedStep = roughStep / magnitude + const niceStep = + normalizedStep <= 1 ? 1 : normalizedStep <= 2 ? 2 : normalizedStep <= 2.5 ? 2.5 : normalizedStep <= 5 ? 5 : 10 + + return niceStep * magnitude * intervals +} + +function buildNonNegativeEvenTicks(values: Array, floor = 0): number[] | undefined { + const finiteValues = values.filter((value): value is number => typeof value === 'number' && Number.isFinite(value)) + + if (!finiteValues.length || finiteValues.some((value) => value < -Y_AXIS_ZERO_EPSILON)) { + return undefined + } + + const maxValue = Math.max(...finiteValues) + if (maxValue <= floor) { + return [floor] + } + + const intervals = EVEN_Y_AXIS_TICK_COUNT - 1 + const ceiling = floor + getNiceCeiling((maxValue - floor) * Y_AXIS_HEADROOM_MULTIPLIER, intervals) + + return Array.from({ length: EVEN_Y_AXIS_TICK_COUNT }, (_, index) => floor + ((ceiling - floor) * index) / intervals) +} + +type TPortfolioHistoryChartControlsProps = { + activeTab: TPortfolioHistoryChartTab + onActiveTabChange: (tab: TPortfolioHistoryChartTab) => void + denomination: TPortfolioHistoryDenomination + onDenominationChange: (denomination: TPortfolioHistoryDenomination) => void + timeframe: TPortfolioHistoryChartTimeframe + onTimeframeChange: (timeframe: TPortfolioHistoryChartTimeframe) => void + resolvedGrowthDisplayMode: TGrowthDisplayMode + onGrowthDisplayModeOverrideChange: (mode: TGrowthDisplayMode | null) => void + vaultGrowthMode: TPortfolioVaultGrowthChartMode + onVaultGrowthModeChange: (mode: TPortfolioVaultGrowthChartMode) => void + isEthGrowthAvailable?: boolean + children?: ReactElement + className?: string +} + +const CHART_TABS: Array<{ id: TPortfolioHistoryChartTab; label: string }> = [ + { id: 'balance', label: 'Balance' }, + { id: 'growth', label: 'Growth' }, + { id: 'annualized', label: 'Annualized %' }, + { id: 'index', label: 'Growth Index' } +] +const TIMEFRAME_OPTIONS: Array<{ id: TPortfolioHistoryChartTimeframe; label: string }> = [ + { id: '30d', label: '30D' }, + { id: '90d', label: '90D' }, + { id: '1y', label: '1Y' }, + { id: 'all', label: 'ALL' } +] +const GROWTH_DISPLAY_MODES: Array<{ id: TGrowthDisplayMode; label: string }> = [ + { id: 'index', label: 'Index' }, + { id: 'usd', label: 'USD' }, + { id: 'eth', label: 'ETH' } +] +const VAULT_GROWTH_VALUE_TYPES: Array<{ id: TPortfolioVaultGrowthChartMode; label: string }> = [ + { id: 'position', label: 'Position' }, + { id: 'index', label: 'Index' } +] +const INDEX_SERIES_COLORS = ['#2578ff', '#46a2ff', '#94adf2', '#7bb3a8', '#e1a23b', '#b67ae5'] as const +const PORTFOLIO_CHART_MARGIN = { + ...CHART_WITH_AXES_MARGIN, + bottom: 4 +} + +const EXAMPLE_PORTFOLIO_USD_DATA: TPortfolioHistoryChartData = [ + { date: '2025-05-01', value: 1800 }, + { date: '2025-06-01', value: 2600 }, + { date: '2025-07-01', value: 2450 }, + { date: '2025-08-01', value: 3900 }, + { date: '2025-09-01', value: 5100 }, + { date: '2025-10-01', value: 6800 }, + { date: '2025-11-01', value: 6400 }, + { date: '2025-12-01', value: 8900 }, + { date: '2026-01-01', value: 10450 }, + { date: '2026-02-01', value: 12100 }, + { date: '2026-03-01', value: 14800 }, + { date: '2026-04-01', value: 17250 } +] + +function formatBalanceValue(value: number, denomination: TPortfolioHistoryDenomination): string { + if (denomination === 'eth') { + return `${value.toFixed(value >= 100 ? 2 : value >= 1 ? 3 : 4)} ETH` + } + + return formatUSD(value, 2, 2) +} + +function formatEthValue(value: number): string { + const absoluteValue = Math.abs(value) + const formattedValue = + absoluteValue >= 100 + ? absoluteValue.toFixed(2) + : absoluteValue >= 1 + ? absoluteValue.toFixed(3) + : absoluteValue.toFixed(4) + + return `${formattedValue} ETH` +} + +function formatGrowthValue(value: number, mode: TGrowthDisplayMode): string { + if (mode === 'eth') { + const absolute = formatEthValue(value) + if (value > 0) { + return `+${absolute}` + } + if (value < 0) { + return `-${absolute}` + } + return absolute + } + + const absolute = formatUSD(Math.abs(value), 2, 2) + if (value > 0) { + return `+${absolute}` + } + if (value < 0) { + return `-${absolute}` + } + return absolute +} + +function formatReturnValue(value: number): string { + const absolute = formatPercent(Math.abs(value), 2, 2, 10_000) + if (value > 0) { + return `+${absolute}` + } + if (value < 0) { + return `-${absolute}` + } + return absolute +} + +function formatIndexValue(value: number): string { + return value >= 1000 ? value.toFixed(0) : value >= 100 ? value.toFixed(1) : value.toFixed(2) +} + +function rebaseIndexPoints(points: TChartPoint[]): TChartPoint[] { + const baseValue = points.find( + (point): point is { date: string; value: number } => + typeof point.value === 'number' && Number.isFinite(point.value) && point.value !== 0 + )?.value + + if (!baseValue) { + return points + } + + return points.map((point) => ({ + date: point.date, + value: + typeof point.value === 'number' && Number.isFinite(point.value) ? (point.value / baseValue) * 100 : point.value + })) +} + +function rebaseDeltaPoints(points: TChartPoint[]): TChartPoint[] { + const baseValue = points.find( + (point): point is { date: string; value: number } => typeof point.value === 'number' && Number.isFinite(point.value) + )?.value + + if (baseValue === undefined) { + return points + } + + return points.map((point) => ({ + date: point.date, + value: typeof point.value === 'number' && Number.isFinite(point.value) ? point.value - baseValue : point.value + })) +} + +function PortfolioChartDropdown({ + label, + value, + options, + onChange, + className +}: { + label: string + value: TValue | '' + options: Array<{ id: TValue; label: string }> + onChange: (value: TValue) => void + className?: string +}): ReactElement { + return ( + + ) +} + +function resolveGrowthDisplayMode( + selectedMode: TGrowthDisplayMode, + protocolReturnData: TPortfolioProtocolReturnHistoryChartData | null +): TGrowthDisplayMode { + const hasEthSeries = Boolean(protocolReturnData?.some((point) => point.growthWeightEth !== null)) + + return selectedMode === 'eth' && !hasEthSeries ? 'index' : selectedMode +} + +export function resolvePortfolioGrowthDisplayMode( + selectedMode: TGrowthDisplayMode, + protocolReturnData: TPortfolioProtocolReturnHistoryChartData | null +): TGrowthDisplayMode { + return resolveGrowthDisplayMode(selectedMode, protocolReturnData) +} + +function PortfolioHistoryChartLoading({ + serverProgress +}: { + serverProgress?: TPortfolioHistoryChartProps['loadingProgress'] +}): ReactElement { + const displayedMessage = serverProgress?.message ?? 'Building portfolio history' + const detail = serverProgress?.detail + + return ( +
+ + {displayedMessage} + {detail ? {detail} : null} + {serverProgress ? ( +
+
+
+ ) : null} +
+ ) +} + +export function PortfolioHistoryChartControls({ + activeTab, + onActiveTabChange, + denomination, + onDenominationChange, + timeframe, + onTimeframeChange, + resolvedGrowthDisplayMode, + onGrowthDisplayModeOverrideChange, + vaultGrowthMode, + onVaultGrowthModeChange, + isEthGrowthAvailable = true, + children, + className +}: TPortfolioHistoryChartControlsProps): ReactElement { + const unitOptions = GROWTH_DISPLAY_MODES.map((mode) => { + const isAvailable = + activeTab === 'balance' + ? mode.id !== 'index' + : activeTab === 'growth' + ? mode.id !== 'eth' || isEthGrowthAvailable + : activeTab === 'index' + ? mode.id === 'index' + : false + const isActive = + activeTab === 'balance' + ? denomination === mode.id + : activeTab === 'growth' + ? resolvedGrowthDisplayMode === mode.id + : activeTab === 'index' && mode.id === 'index' + + return { ...mode, isActive, isAvailable } + }) + const valueTypeOptions: Array<{ id: TPortfolioHistoryValueType; label: string }> = + activeTab === 'index' + ? VAULT_GROWTH_VALUE_TYPES + : unitOptions + .filter((mode) => mode.isAvailable) + .map((mode) => ({ + id: mode.id, + label: mode.label + })) + const activeUnitValue: TPortfolioHistoryValueType | '' = + activeTab === 'index' ? vaultGrowthMode : (unitOptions.find((mode) => mode.isActive)?.id ?? '') + const shouldShowValueTypeSelector = activeTab !== 'annualized' + + const handleValueTypeChange = (mode: TPortfolioHistoryValueType): void => { + if (activeTab === 'balance') { + if (mode === 'usd' || mode === 'eth') { + onDenominationChange(mode) + } + return + } + + if (activeTab === 'growth') { + if (mode === 'index' || mode === 'usd' || mode === 'eth') { + onGrowthDisplayModeOverrideChange(mode) + } + return + } + + if (activeTab === 'index') { + if (mode === 'position' || mode === 'index') { + onVaultGrowthModeChange(mode) + } + } + } + + const handleChartTabChange = (tab: TPortfolioHistoryChartTab): void => { + onActiveTabChange(tab) + if (tab === 'index') { + onVaultGrowthModeChange('index') + } + } + + return ( +
+
+ +
+ {CHART_TABS.map((tab) => ( + + ))} +
+
+ + {shouldShowValueTypeSelector ? ( + + ) : null} +
+
+ {children} +
+ ) +} + +function buildPortfolioVaultGrowthSeries( + familySeries: TPortfolioProtocolReturnHistoryFamilySeries, + labelByVaultKey: Record +): TPortfolioVaultGrowthChartSeries[] { + return familySeries.map((series) => ({ + vaultAddress: series.vaultAddress, + vaultName: + labelByVaultKey[`${series.chainId}:${series.vaultAddress.toLowerCase()}`] ?? + series.symbol ?? + `${series.vaultAddress.slice(0, 6)}…${series.vaultAddress.slice(-4)}`, + symbol: series.symbol, + points: series.dataPoints.map((point) => ({ + timestamp: point.timestamp, + positionValueUsd: point.growthWeightUsd, + indexValue: point.growthIndex + })) + })) +} + +function PortfolioHistoryTooltip({ + active, + payload, + activeTab, + denomination, + growthDisplayMode +}: TPortfolioHistoryTooltipProps & { + activeTab: TPortfolioHistoryChartTab + denomination: TPortfolioHistoryDenomination + growthDisplayMode: TGrowthDisplayMode +}): ReactElement | null { + if (!active || !payload?.length) { + return null + } + + const point = payload[0]?.payload + const date = point?.date + const value = Number(payload[0]?.value ?? point?.value ?? 0) + + if (!date) { + return null + } + + const formattedValue = + activeTab === 'balance' + ? formatBalanceValue(value, denomination) + : activeTab === 'growth' + ? growthDisplayMode === 'index' + ? formatIndexValue(value) + : formatGrowthValue(value, growthDisplayMode) + : formatReturnValue(value) + + return ( +
+
+ + {formatChartTooltipDate(date)} + + {formattedValue} +
+ {activeTab === 'balance' ? ( + + {value > 0 && !point?.isLive ? 'Click to see breakdown' : 'No breakdown available for this point'} + + ) : null} +
+ ) +} + +function getEmptyMessage(activeTab: TPortfolioHistoryChartTab, growthDisplayMode: TGrowthDisplayMode): string { + if (activeTab === 'growth') { + return growthDisplayMode === 'index' + ? 'No growth index history available' + : growthDisplayMode === 'eth' + ? 'No ETH-equivalent protocol growth history available' + : 'No protocol growth history available' + } + + if (activeTab === 'annualized') { + return 'No annualized return history available' + } + + if (activeTab === 'index') { + return 'No growth index history available' + } + + return 'No holdings history available' +} + +export function PortfolioHistoryChart({ + balanceData, + protocolReturnData, + protocolReturnSummary, + protocolReturnFamilySeries, + denomination, + timeframe, + activeTab, + growthDisplayModeOverride, + onGrowthDisplayModeOverrideChange, + vaultGrowthMode, + onVaultGrowthModeChange, + balanceIsLoading, + balanceIsEmpty = false, + balanceError, + protocolReturnIsLoading, + protocolReturnIsEmpty = false, + protocolReturnError, + embedded = false, + reserveControlSpace = true, + loadingProgress, + className +}: TPortfolioHistoryChartProps): ReactElement { + const { address } = useWeb3() + const { allVaults } = useYearn() + const [hoveredBreakdownDate, setHoveredBreakdownDate] = useState(null) + const [selectedBreakdownDate, setSelectedBreakdownDate] = useState(null) + const [isBreakdownModalOpen, setIsBreakdownModalOpen] = useState(false) + const gradientId = useId().replace(/:/g, '') + const recommendedGrowthDisplayMode = resolveGrowthDisplayMode( + protocolReturnSummary?.recommendedGrowthDisplay ?? 'index', + protocolReturnData + ) + const resolvedGrowthDisplayMode = resolveGrowthDisplayMode( + growthDisplayModeOverride ?? recommendedGrowthDisplayMode, + protocolReturnData + ) + + useEffect(() => { + onGrowthDisplayModeOverrideChange(null) + }, [address, onGrowthDisplayModeOverrideChange]) + + const sectionClassName = embedded + ? reserveControlSpace + ? 'flex h-full min-h-[260px] flex-col bg-surface px-5 pt-16 pb-2 md:min-h-0 md:px-6 md:pt-16 md:pb-3' + : 'flex h-full min-h-0 flex-col bg-surface p-5 md:p-6' + : 'flex h-full flex-col gap-4 rounded-lg border border-border bg-surface p-6' + + const filteredBalanceData = useMemo(() => { + if (!balanceData) { + return [] + } + + const limit = getTimeframeLimit(timeframe) + const points = !Number.isFinite(limit) || limit >= balanceData.length ? balanceData : balanceData.slice(-limit) + return points.map((point) => ({ date: point.date, value: point.value, isLive: point.isLive })) + }, [balanceData, timeframe]) + + const filteredGrowthUsdData = useMemo(() => { + if (!protocolReturnData) { + return [] + } + + const limit = getTimeframeLimit(timeframe) + const points = + !Number.isFinite(limit) || limit >= protocolReturnData.length + ? protocolReturnData + : protocolReturnData.slice(-limit) + + return rebaseDeltaPoints( + points.map((point) => ({ + date: point.date, + value: point.growthWeightUsd + })) + ) + }, [protocolReturnData, timeframe]) + + const filteredGrowthEthData = useMemo(() => { + if (!protocolReturnData) { + return [] + } + + const limit = getTimeframeLimit(timeframe) + const points = + !Number.isFinite(limit) || limit >= protocolReturnData.length + ? protocolReturnData + : protocolReturnData.slice(-limit) + + return rebaseDeltaPoints( + points.map((point) => ({ + date: point.date, + value: point.growthWeightEth + })) + ) + }, [protocolReturnData, timeframe]) + + const filteredGrowthIndexData = useMemo(() => { + if (!protocolReturnData) { + return [] + } + + const limit = getTimeframeLimit(timeframe) + const points = + !Number.isFinite(limit) || limit >= protocolReturnData.length + ? protocolReturnData + : protocolReturnData.slice(-limit) + + return rebaseIndexPoints( + points.map((point) => ({ + date: point.date, + value: point.growthIndex + })) + ) + }, [protocolReturnData, timeframe]) + + const filteredAnnualizedReturnData = useMemo(() => { + if (!protocolReturnData) { + return [] + } + + const limit = getTimeframeLimit(timeframe) + const points = + !Number.isFinite(limit) || limit >= protocolReturnData.length + ? protocolReturnData + : protocolReturnData.slice(-limit) + + return points.map((point) => ({ + date: point.date, + value: point.annualizedProtocolReturnPct + })) + }, [protocolReturnData, timeframe]) + + const familyLabelByVaultKey = useMemo>(() => { + return Object.values(allVaults).reduce>((labels, vault) => { + labels[`${vault.chainId}:${getVaultAddress(vault).toLowerCase()}`] = getDisplayVaultName(vault) + return labels + }, {}) + }, [allVaults]) + + const activeData = + activeTab === 'balance' + ? filteredBalanceData + : activeTab === 'growth' + ? resolvedGrowthDisplayMode === 'eth' + ? filteredGrowthEthData + : resolvedGrowthDisplayMode === 'usd' + ? filteredGrowthUsdData + : filteredGrowthIndexData + : activeTab === 'index' + ? filteredGrowthIndexData + : filteredAnnualizedReturnData + const activeIsLoading = activeTab === 'balance' ? balanceIsLoading : protocolReturnIsLoading + const activeIsEmpty = activeTab === 'balance' ? balanceIsEmpty : protocolReturnIsEmpty + const activeError = activeTab === 'balance' ? balanceError : protocolReturnError + const activeHasRenderableValue = activeData.some((point) => point.value !== null) + const yAxisFloor = activeTab === 'growth' && resolvedGrowthDisplayMode === 'index' ? 100 : 0 + const yAxisTicks = useMemo( + () => + buildNonNegativeEvenTicks( + activeData.map((point) => point.value), + yAxisFloor + ), + [activeData, yAxisFloor] + ) + const yAxisDomain = useMemo( + () => (yAxisTicks ? [yAxisFloor, yAxisTicks.at(-1) ?? yAxisFloor] : NON_NEGATIVE_AUTO_DOMAIN), + [yAxisFloor, yAxisTicks] + ) + const tickSourceData = activeData + + const isShortTimeframe = timeframe === '30d' + const ticks = useMemo( + () => (isShortTimeframe ? getChartWeeklyTicks(tickSourceData) : getChartMonthlyTicks(tickSourceData)), + [tickSourceData, isShortTimeframe] + ) + const tickFormatter = isShortTimeframe ? formatChartWeekLabel : formatChartMonthYearLabel + + const formatValueTick = (value: number | string, index?: number) => { + if (index === 0) { + return '' + } + + const numericValue = Number(value) + const absoluteValue = Math.abs(numericValue) + if (numericValue === 0) { + return '' + } + + if (activeTab === 'balance') { + if (denomination === 'eth') { + if (absoluteValue >= 1_000) { + return `${numericValue < 0 ? '-' : ''}${(absoluteValue / 1_000).toFixed(1)}k` + } + return numericValue >= 10 || numericValue <= -10 ? numericValue.toFixed(0) : numericValue.toFixed(2) + } + + if (absoluteValue >= 1_000_000) { + return `${numericValue < 0 ? '-' : ''}$${(absoluteValue / 1_000_000).toFixed(1)}M` + } + if (absoluteValue >= 1_000) { + return `${numericValue < 0 ? '-' : ''}$${(absoluteValue / 1_000).toFixed(1)}k` + } + return `${numericValue < 0 ? '-' : ''}$${absoluteValue.toFixed(0)}` + } + + if (activeTab === 'growth') { + if (resolvedGrowthDisplayMode === 'index') { + return formatIndexValue(numericValue) + } + + if (resolvedGrowthDisplayMode === 'eth') { + if (absoluteValue >= 1_000) { + return `${numericValue < 0 ? '-' : ''}${(absoluteValue / 1_000).toFixed(1)}k` + } + + return absoluteValue >= 10 + ? `${numericValue.toFixed(1)}` + : absoluteValue >= 1 + ? `${numericValue.toFixed(2)}` + : `${numericValue.toFixed(3)}` + } + + if (absoluteValue >= 1_000_000) { + return `${numericValue < 0 ? '-' : ''}$${(absoluteValue / 1_000_000).toFixed(1)}M` + } + if (absoluteValue >= 1_000) { + return `${numericValue < 0 ? '-' : ''}$${(absoluteValue / 1_000).toFixed(1)}k` + } + return `${numericValue < 0 ? '-' : ''}$${absoluteValue.toFixed(0)}` + } + + if (activeTab === 'index') { + return formatIndexValue(numericValue) + } + + if (absoluteValue >= 1000) { + return `${numericValue.toFixed(0)}%` + } + if (absoluteValue >= 10) { + return `${numericValue.toFixed(1)}%` + } + return `${numericValue.toFixed(2)}%` + } + + const chartConfig = useMemo(() => { + return { + value: { + label: + activeTab === 'balance' + ? denomination === 'eth' + ? 'Total Value (ETH)' + : 'Total Value (USD)' + : activeTab === 'growth' + ? resolvedGrowthDisplayMode === 'index' + ? 'Growth Index' + : resolvedGrowthDisplayMode === 'eth' + ? 'Protocol Growth (ETH)' + : 'Protocol Growth (USD)' + : activeTab === 'index' + ? 'Growth Index' + : 'Protocol Return (%)', + color: 'var(--chart-1)' + } + } + }, [activeTab, denomination, resolvedGrowthDisplayMode]) + + const exampleChartConfig = useMemo(() => { + return { + value: { + label: denomination === 'eth' ? 'Example Value (ETH)' : 'Example Value (USD)', + color: 'var(--chart-1)' + } + } + }, [denomination]) + + const exampleData = useMemo( + () => + denomination === 'eth' + ? EXAMPLE_PORTFOLIO_USD_DATA.map((point) => ({ ...point, value: point.value / 2500 })) + : EXAMPLE_PORTFOLIO_USD_DATA, + [denomination] + ) + + const getBreakdownPoint = (date: string | null) => { + if (!date || activeTab !== 'balance') { + return null + } + + const point = filteredBalanceData.find((balancePoint) => balancePoint.date === date) ?? null + return point?.isLive ? null : point + } + + const handleChartMouseMove = (state: TActiveChartState | undefined): void => { + if (activeTab !== 'balance') { + return + } + + const nextDate = typeof state?.activeLabel === 'string' ? state.activeLabel : null + setHoveredBreakdownDate((currentDate) => (currentDate === nextDate ? currentDate : nextDate)) + } + + const handleChartMouseLeave = (): void => { + if (activeTab !== 'balance') { + return + } + + setHoveredBreakdownDate(null) + } + + const handleChartClick = (state?: TActiveChartState): void => { + if (activeTab !== 'balance') { + return + } + + const clickedDate = typeof state?.activeLabel === 'string' ? state.activeLabel : hoveredBreakdownDate + const selectedPoint = getBreakdownPoint(clickedDate) + if (!selectedPoint || Number(selectedPoint.value ?? 0) <= 0) { + return + } + + setSelectedBreakdownDate(selectedPoint.date) + setIsBreakdownModalOpen(true) + } + + if (activeIsLoading) { + return ( +
+ +
+ ) + } + + if (activeError) { + return ( +
+
+

+ {activeTab === 'balance' + ? 'Unable to load holdings history right now' + : 'Unable to load protocol return history right now'} +

+
+
+ ) + } + + if (activeIsEmpty && activeTab === 'balance') { + return ( +
+
+
+ + + + + + + + + + + + + + + +
+
+
+
+
+

+ { + 'Once you have a deposit in a Yearn Vault, you’ll see your portfolio balances and growth over time here.' + } +

+
+
+ + {'Explore Vaults'} + +
+
+
+
+ ) + } + + if ((activeIsEmpty || activeData.length === 0 || !activeHasRenderableValue) && activeTab !== 'index') { + return ( +
+
+

{getEmptyMessage(activeTab, resolvedGrowthDisplayMode)}

+
+
+ ) + } + + if (activeTab === 'index') { + const vaultGrowthSeries = buildPortfolioVaultGrowthSeries(protocolReturnFamilySeries, familyLabelByVaultKey) + + return ( +
+ + setIsBreakdownModalOpen(false)} + /> +
+ ) + } + + return ( +
+
+ 0 + ? 'cursor-pointer' + : undefined + } + > + + + + + + + + + + + ( + + )} + /> + + + + +
+ setIsBreakdownModalOpen(false)} + /> +
+ ) +} diff --git a/src/components/pages/portfolio/components/PortfolioVaultGrowthChart.tsx b/src/components/pages/portfolio/components/PortfolioVaultGrowthChart.tsx new file mode 100644 index 000000000..7842158c3 --- /dev/null +++ b/src/components/pages/portfolio/components/PortfolioVaultGrowthChart.tsx @@ -0,0 +1,768 @@ +import type { ChartConfig } from '@pages/vaults/components/detail/charts/ChartPrimitives' +import { ChartContainer, ChartTooltip } from '@pages/vaults/components/detail/charts/ChartPrimitives' +import { + CHART_WITH_AXES_MARGIN, + CHART_Y_AXIS_TICK_MARGIN, + CHART_Y_AXIS_TICK_STYLE, + CHART_Y_AXIS_WIDTH +} from '@pages/vaults/components/detail/charts/chartLayout' +import { + formatChartMonthYearLabel, + formatChartTooltipDate, + formatChartWeekLabel, + formatUnixTimestamp, + getChartMonthlyTicks, + getChartWeeklyTicks, + getTimeframeLimit +} from '@pages/vaults/utils/charts' +import { cl, formatUSD, SELECTOR_BAR_STYLES } from '@shared/utils' +import type { ReactElement } from 'react' +import { useMemo, useState } from 'react' +import { CartesianGrid, ComposedChart, Line, XAxis, YAxis } from 'recharts' +import type { AxisDomain } from 'recharts/types/util/types' + +export type TPortfolioVaultGrowthChartMode = 'position' | 'index' +export type TPortfolioVaultGrowthChartTimeframe = '30d' | '90d' | '1y' | 'all' + +const NON_NEGATIVE_AUTO_DOMAIN: AxisDomain = [ + (dataMin: number) => (Number.isFinite(dataMin) && dataMin < 0 ? dataMin : 0), + (dataMax: number) => (Number.isFinite(dataMax) ? dataMax : 0) +] +const EVEN_Y_AXIS_TICK_COUNT = 5 +const Y_AXIS_ZERO_EPSILON = 1e-9 +const Y_AXIS_HEADROOM_MULTIPLIER = 1.05 + +function getNiceCeiling(value: number, intervals: number): number { + const roughStep = value / intervals + const magnitude = 10 ** Math.floor(Math.log10(roughStep)) + const normalizedStep = roughStep / magnitude + const niceStep = + normalizedStep <= 1 ? 1 : normalizedStep <= 2 ? 2 : normalizedStep <= 2.5 ? 2.5 : normalizedStep <= 5 ? 5 : 10 + + return niceStep * magnitude * intervals +} + +function buildNonNegativeEvenTicks(values: Array, floor = 0): number[] | undefined { + const finiteValues = values.filter((value): value is number => typeof value === 'number' && Number.isFinite(value)) + + if (!finiteValues.length || finiteValues.some((value) => value < -Y_AXIS_ZERO_EPSILON)) { + return undefined + } + + const maxValue = Math.max(...finiteValues) + if (maxValue <= floor) { + return [floor] + } + + const intervals = EVEN_Y_AXIS_TICK_COUNT - 1 + const ceiling = floor + getNiceCeiling((maxValue - floor) * Y_AXIS_HEADROOM_MULTIPLIER, intervals) + + return Array.from({ length: EVEN_Y_AXIS_TICK_COUNT }, (_, index) => floor + ((ceiling - floor) * index) / intervals) +} + +export type TPortfolioVaultGrowthChartPoint = { + timestamp: number + vaultAddress: string + vaultName: string + symbol: string + pricePerShare: number + underlyingUsdPrice: number + userShareBalance: number +} + +export type TPortfolioVaultGrowthChartSeriesPoint = { + timestamp: number + positionValueUsd: number | null + indexValue: number | null +} + +export type TPortfolioVaultGrowthChartSeries = { + vaultAddress: string + vaultName: string + symbol?: string | null + points: TPortfolioVaultGrowthChartSeriesPoint[] +} + +export type TPortfolioVaultGrowthChartProps = { + points?: TPortfolioVaultGrowthChartPoint[] + series?: TPortfolioVaultGrowthChartSeries[] + mode?: TPortfolioVaultGrowthChartMode + initialMode?: TPortfolioVaultGrowthChartMode + onModeChange?: (mode: TPortfolioVaultGrowthChartMode) => void + timeframe?: TPortfolioVaultGrowthChartTimeframe + vaultOrder?: string[] + maxVaults?: number + indexBase?: number + colors?: string[] + title?: string + height?: number | string + showModeToggle?: boolean + className?: string + emptyMessage?: string +} + +type TTransformedPoint = { + timestamp: number + date: string + value: number | null +} + +type TTransformedSeries = { + key: string + vaultAddress: string + label: string + color: string + positionPoints: TTransformedPoint[] + indexPoints: TTransformedPoint[] +} + +type TChartPoint = { + date: string + timestamp: number + [seriesKey: string]: string | number | null +} + +type TTooltipProps = { + active?: boolean + payload?: Array<{ + dataKey?: unknown + color?: unknown + value?: unknown + payload?: { + date?: string + [seriesKey: string]: unknown + } + }> +} + +const DEFAULT_COLORS = ['#2578ff', '#46a2ff', '#94adf2', '#7bb3a8', '#e1a23b', '#b67ae5', '#f472b6', '#f97316'] +const PORTFOLIO_VAULT_GROWTH_CHART_MARGIN = { + ...CHART_WITH_AXES_MARGIN, + bottom: 4 +} +const MIN_RELEVANCE_SCORE = 0.000001 + +const MODE_COPY: Record = { + position: 'Shows actual protocol gain from your deposited positions during the selected timeframe.', + index: 'Shows vault performance normalized to 100, ignoring position size.' +} + +function getVaultKey(address: string): string { + return address.toLowerCase() +} + +function normalizeTimestamp(timestamp: number): number { + return timestamp > 1_000_000_000_000 ? Math.floor(timestamp / 1000) : Math.floor(timestamp) +} + +function isFinitePositive(value: number): boolean { + return Number.isFinite(value) && value > 0 +} + +function isFiniteNumber(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value) +} + +function getValidShareBalance(point: TPortfolioVaultGrowthChartPoint): number { + return Number.isFinite(point.userShareBalance) && point.userShareBalance > 0 ? point.userShareBalance : 0 +} + +function hasRawExposure(points: TPortfolioVaultGrowthChartPoint[]): boolean { + return points.some((point) => getValidShareBalance(point) > 0) +} + +function hasPrecomputedExposure(points: TPortfolioVaultGrowthChartSeriesPoint[]): boolean { + return points.some((point) => isFiniteNumber(point.positionValueUsd) || isFiniteNumber(point.indexValue)) +} + +function buildPositionPoints(points: TPortfolioVaultGrowthChartPoint[]): TTransformedPoint[] { + return points.reduce<{ + points: TTransformedPoint[] + cumulativeGrowthUsd: number + previousPricePerShare: number | null + previousShareBalance: number + hasSeenExposure: boolean + }>( + (state, point) => { + const timestamp = normalizeTimestamp(point.timestamp) + const shareBalance = getValidShareBalance(point) + const hasValidPrices = isFinitePositive(point.pricePerShare) && isFinitePositive(point.underlyingUsdPrice) + + if (!hasValidPrices) { + state.points.push({ timestamp, date: formatUnixTimestamp(timestamp), value: null }) + return state + } + + const cumulativeGrowthUsd = + state.previousPricePerShare !== null + ? state.cumulativeGrowthUsd + + state.previousShareBalance * (point.pricePerShare - state.previousPricePerShare) * point.underlyingUsdPrice + : state.cumulativeGrowthUsd + const hasSeenExposure = state.hasSeenExposure || state.previousShareBalance > 0 || shareBalance > 0 + + state.points.push({ + timestamp, + date: formatUnixTimestamp(timestamp), + value: hasSeenExposure ? cumulativeGrowthUsd : null + }) + + return { + points: state.points, + cumulativeGrowthUsd, + previousPricePerShare: point.pricePerShare, + previousShareBalance: shareBalance, + hasSeenExposure + } + }, + { + points: [], + cumulativeGrowthUsd: 0, + previousPricePerShare: null, + previousShareBalance: 0, + hasSeenExposure: false + } + ).points +} + +function buildIndexPoints(points: TPortfolioVaultGrowthChartPoint[], indexBase: number): TTransformedPoint[] { + const basePricePerShare = points.find( + (point) => getValidShareBalance(point) > 0 && isFinitePositive(point.pricePerShare) + )?.pricePerShare + + return points.map((point) => { + const timestamp = normalizeTimestamp(point.timestamp) + const hasOpenPosition = getValidShareBalance(point) > 0 + const value = + hasOpenPosition && basePricePerShare && isFinitePositive(point.pricePerShare) + ? (point.pricePerShare / basePricePerShare) * indexBase + : null + + return { timestamp, date: formatUnixTimestamp(timestamp), value } + }) +} + +function getFiniteValues(points: TTransformedPoint[]): number[] { + return points.flatMap((point) => (isFiniteNumber(point.value) ? [point.value] : [])) +} + +function getPositionRelevance(points: TTransformedPoint[]): number { + return Math.max(0, ...getFiniteValues(points).map((value) => Math.abs(value))) +} + +function getIndexRelevance(points: TTransformedPoint[], indexBase: number): number { + return Math.max(0, ...getFiniteValues(points).map((value) => Math.abs(value - indexBase))) +} + +function selectRelevantSeries(args: { + series: TTransformedSeries[] + maxVaults?: number + indexBase: number +}): TTransformedSeries[] { + const scoredSeries = args.series.map((vaultSeries) => { + const positionScore = getPositionRelevance(vaultSeries.positionPoints) + const indexScore = getIndexRelevance(vaultSeries.indexPoints, args.indexBase) + return { + vaultSeries, + positionScore, + indexScore, + combinedScore: positionScore + indexScore + } + }) + const hasRelevantSeries = scoredSeries.some( + (series) => series.positionScore > MIN_RELEVANCE_SCORE || series.indexScore > MIN_RELEVANCE_SCORE + ) + const candidates = hasRelevantSeries + ? scoredSeries.filter( + (series) => series.positionScore > MIN_RELEVANCE_SCORE || series.indexScore > MIN_RELEVANCE_SCORE + ) + : scoredSeries + + if (typeof args.maxVaults !== 'number' || candidates.length <= args.maxVaults) { + return candidates.map((series) => series.vaultSeries) + } + + const maxVaults = args.maxVaults + const selectedKeys = new Set() + const selectedSeries: TTransformedSeries[] = [] + const addSeries = (vaultSeries: TTransformedSeries): void => { + if (selectedSeries.length >= maxVaults || selectedKeys.has(vaultSeries.vaultAddress.toLowerCase())) { + return + } + selectedKeys.add(vaultSeries.vaultAddress.toLowerCase()) + selectedSeries.push(vaultSeries) + } + const positionTarget = Math.ceil(maxVaults / 2) + + candidates + .toSorted((left, right) => right.positionScore - left.positionScore || right.combinedScore - left.combinedScore) + .slice(0, positionTarget) + .forEach((series) => { + addSeries(series.vaultSeries) + }) + + candidates + .toSorted((left, right) => right.indexScore - left.indexScore || right.combinedScore - left.combinedScore) + .forEach((series) => { + addSeries(series.vaultSeries) + }) + + candidates + .toSorted((left, right) => right.combinedScore - left.combinedScore) + .forEach((series) => { + addSeries(series.vaultSeries) + }) + + return selectedSeries +} + +function applySeriesPresentation(series: TTransformedSeries[], colors: string[]): TTransformedSeries[] { + return series.map((vaultSeries, index) => ({ + ...vaultSeries, + key: `vault_${index}`, + color: colors[index % colors.length] ?? DEFAULT_COLORS[index % DEFAULT_COLORS.length] + })) +} + +function groupVaultPoints(points: TPortfolioVaultGrowthChartPoint[]): Map { + return points.reduce>((groups, point) => { + const key = getVaultKey(point.vaultAddress) + const existing = groups.get(key) ?? [] + existing.push({ ...point, timestamp: normalizeTimestamp(point.timestamp) }) + groups.set(key, existing) + return groups + }, new Map()) +} + +function buildSeries(args: { + points: TPortfolioVaultGrowthChartPoint[] + timeframe: TPortfolioVaultGrowthChartTimeframe + vaultOrder?: string[] + maxVaults?: number + indexBase: number + colors: string[] +}): TTransformedSeries[] { + const grouped = groupVaultPoints(args.points) + const groupedKeys = Array.from(grouped.keys()) + const orderedKeys = args.vaultOrder?.length + ? args.vaultOrder.map(getVaultKey).filter((key) => grouped.has(key)) + : groupedKeys + const limit = getTimeframeLimit(args.timeframe) + + const transformedSeries = orderedKeys.flatMap((vaultKey) => { + const rawPoints = grouped.get(vaultKey) + if (!rawPoints?.length) { + return [] + } + + const sortedPoints = rawPoints.toSorted((left, right) => left.timestamp - right.timestamp) + const points = !Number.isFinite(limit) || limit >= sortedPoints.length ? sortedPoints : sortedPoints.slice(-limit) + const firstPoint = points[0] + if (!firstPoint || !hasRawExposure(points)) { + return [] + } + + return [ + { + key: vaultKey, + vaultAddress: firstPoint.vaultAddress, + label: firstPoint.vaultName || firstPoint.symbol || firstPoint.vaultAddress, + color: args.colors[0] ?? DEFAULT_COLORS[0], + positionPoints: buildPositionPoints(points), + indexPoints: buildIndexPoints(points, args.indexBase) + } + ] + }) + + return applySeriesPresentation( + selectRelevantSeries({ series: transformedSeries, maxVaults: args.maxVaults, indexBase: args.indexBase }), + args.colors + ) +} + +function buildPrecomputedIndexPoints( + points: TPortfolioVaultGrowthChartSeriesPoint[], + indexBase: number +): TTransformedPoint[] { + const baseValue = points.find((point) => isFiniteNumber(point.indexValue))?.indexValue + + return points.map((point) => { + const timestamp = normalizeTimestamp(point.timestamp) + const value = baseValue && isFiniteNumber(point.indexValue) ? (point.indexValue / baseValue) * indexBase : null + + return { timestamp, date: formatUnixTimestamp(timestamp), value } + }) +} + +function buildPrecomputedPositionPoints(points: TPortfolioVaultGrowthChartSeriesPoint[]): TTransformedPoint[] { + return points.reduce<{ + points: TTransformedPoint[] + baseValue: number | null + lastValue: number | null + }>( + (state, point) => { + const timestamp = normalizeTimestamp(point.timestamp) + const nextLastValue = isFiniteNumber(point.positionValueUsd) ? point.positionValueUsd : state.lastValue + const nextBaseValue = state.baseValue ?? nextLastValue + + state.points.push({ + timestamp, + date: formatUnixTimestamp(timestamp), + value: nextBaseValue !== null && nextLastValue !== null ? nextLastValue - nextBaseValue : null + }) + + return { + points: state.points, + baseValue: nextBaseValue, + lastValue: nextLastValue + } + }, + { + points: [], + baseValue: null, + lastValue: null + } + ).points +} + +function buildSeriesFromPrecomputed(args: { + series: TPortfolioVaultGrowthChartSeries[] + timeframe: TPortfolioVaultGrowthChartTimeframe + vaultOrder?: string[] + maxVaults?: number + indexBase: number + colors: string[] +}): TTransformedSeries[] { + const seriesByVaultKey = new Map( + args.series.map((vaultSeries) => [getVaultKey(vaultSeries.vaultAddress), vaultSeries]) + ) + const orderedKeys = args.vaultOrder?.length + ? args.vaultOrder.map(getVaultKey).filter((key) => seriesByVaultKey.has(key)) + : Array.from(seriesByVaultKey.keys()) + const limit = getTimeframeLimit(args.timeframe) + + const transformedSeries = orderedKeys.flatMap((vaultKey) => { + const vaultSeries = seriesByVaultKey.get(vaultKey) + if (!vaultSeries?.points.length) { + return [] + } + + const sortedPoints = vaultSeries.points + .map((point) => ({ ...point, timestamp: normalizeTimestamp(point.timestamp) })) + .toSorted((left, right) => left.timestamp - right.timestamp) + const points = !Number.isFinite(limit) || limit >= sortedPoints.length ? sortedPoints : sortedPoints.slice(-limit) + if (!hasPrecomputedExposure(points)) { + return [] + } + + return [ + { + key: vaultKey, + vaultAddress: vaultSeries.vaultAddress, + label: vaultSeries.vaultName || vaultSeries.symbol || vaultSeries.vaultAddress, + color: args.colors[0] ?? DEFAULT_COLORS[0], + positionPoints: buildPrecomputedPositionPoints(points), + indexPoints: buildPrecomputedIndexPoints(points, args.indexBase) + } + ] + }) + + return applySeriesPresentation( + selectRelevantSeries({ series: transformedSeries, maxVaults: args.maxVaults, indexBase: args.indexBase }), + args.colors + ) +} + +function buildChartData(series: TTransformedSeries[], mode: TPortfolioVaultGrowthChartMode): TChartPoint[] { + const timestamps = Array.from( + new Set( + series.flatMap((vaultSeries) => + (mode === 'position' ? vaultSeries.positionPoints : vaultSeries.indexPoints).map((point) => point.timestamp) + ) + ) + ).toSorted((left, right) => left - right) + + return timestamps.map((timestamp) => { + const row: TChartPoint = { timestamp, date: formatUnixTimestamp(timestamp) } + + series.forEach((vaultSeries) => { + const points = mode === 'position' ? vaultSeries.positionPoints : vaultSeries.indexPoints + row[vaultSeries.key] = points.find((point) => point.timestamp === timestamp)?.value ?? null + }) + + return row + }) +} + +function formatPositionValue(value: number): string { + const absolute = formatUSD(Math.abs(value), 2, 2) + if (value > 0) { + return `+${absolute}` + } + if (value < 0) { + return `-${absolute}` + } + return absolute +} + +function formatIndexValue(value: number): string { + return value >= 1000 ? value.toFixed(0) : value >= 100 ? value.toFixed(1) : value.toFixed(2) +} + +function formatPositionTick(value: number | string, index?: number): string { + if (index === 0) { + return '' + } + + const numericValue = Number(value) + const absoluteValue = Math.abs(numericValue) + if (absoluteValue >= 1_000_000) { + return `${numericValue < 0 ? '-' : ''}$${(absoluteValue / 1_000_000).toFixed(1)}M` + } + if (absoluteValue >= 1_000) { + return `${numericValue < 0 ? '-' : ''}$${(absoluteValue / 1_000).toFixed(1)}k` + } + return `${numericValue < 0 ? '-' : ''}$${absoluteValue.toFixed(0)}` +} + +function formatIndexTick(value: number | string, index?: number): string { + if (index === 0) { + return '' + } + + const numericValue = Number(value) + return Math.abs(numericValue) >= 1000 ? numericValue.toFixed(0) : numericValue.toFixed(1) +} + +function PortfolioVaultGrowthTooltip({ + active, + payload, + mode, + seriesLabels +}: TTooltipProps & { + mode: TPortfolioVaultGrowthChartMode + seriesLabels: Record +}): ReactElement | null { + if (!active || !payload?.length) { + return null + } + + const date = payload[0]?.payload?.date + if (!date) { + return null + } + + const rows = payload + .flatMap((entry) => { + const dataKey = typeof entry.dataKey === 'string' ? entry.dataKey : '' + const value = typeof entry.value === 'number' ? entry.value : Number(entry.value ?? NaN) + + if (!Number.isFinite(value)) { + return [] + } + + return [ + { + key: dataKey, + label: seriesLabels[dataKey] ?? dataKey, + value, + color: typeof entry.color === 'string' ? entry.color : 'var(--color-text-primary)' + } + ] + }) + .toSorted((left, right) => right.value - left.value) + + return ( +
+ + {formatChartTooltipDate(date)} + +
+ {rows.map((row) => ( +
+ + + {row.label} + + + {mode === 'position' ? formatPositionValue(row.value) : formatIndexValue(row.value)} + +
+ ))} +
+
+ ) +} + +export function PortfolioVaultGrowthChart({ + points = [], + series: precomputedSeries, + mode, + initialMode = 'position', + onModeChange, + timeframe = 'all', + vaultOrder, + maxVaults, + indexBase = 100, + colors = DEFAULT_COLORS, + title = 'Vault Growth', + height = 300, + showModeToggle = true, + className, + emptyMessage = 'No vault growth history available' +}: TPortfolioVaultGrowthChartProps): ReactElement { + const [uncontrolledMode, setUncontrolledMode] = useState(initialMode) + const activeMode = mode ?? uncontrolledMode + const series = useMemo(() => { + if (precomputedSeries) { + return buildSeriesFromPrecomputed({ + series: precomputedSeries, + timeframe, + vaultOrder, + maxVaults, + indexBase, + colors + }) + } + + return buildSeries({ points, timeframe, vaultOrder, maxVaults, indexBase, colors }) + }, [colors, indexBase, maxVaults, points, precomputedSeries, timeframe, vaultOrder]) + const chartData = useMemo(() => buildChartData(series, activeMode), [activeMode, series]) + const yAxisFloor = activeMode === 'index' ? 100 : 0 + const yAxisTicks = useMemo( + () => + buildNonNegativeEvenTicks( + chartData.flatMap((point) => + Object.entries(point).flatMap(([key, value]) => { + if (key === 'timestamp' || key === 'date') { + return [] + } + return typeof value === 'number' ? [value] : [] + }) + ), + yAxisFloor + ), + [chartData, yAxisFloor] + ) + const yAxisDomain = useMemo( + () => (yAxisTicks ? [yAxisFloor, yAxisTicks.at(-1) ?? yAxisFloor] : NON_NEGATIVE_AUTO_DOMAIN), + [yAxisFloor, yAxisTicks] + ) + const chartConfig = useMemo(() => { + return Object.fromEntries( + series.map((vaultSeries) => [vaultSeries.key, { label: vaultSeries.label, color: vaultSeries.color }]) + ) + }, [series]) + const seriesLabels = useMemo>(() => { + return Object.fromEntries(series.map((vaultSeries) => [vaultSeries.key, vaultSeries.label])) + }, [series]) + const hasRenderableValue = chartData.some((point) => + series.some((vaultSeries) => typeof point[vaultSeries.key] === 'number' && Number.isFinite(point[vaultSeries.key])) + ) + const isShortRange = timeframe === '30d' || chartData.length <= 45 + const ticks = isShortRange ? getChartWeeklyTicks(chartData) : getChartMonthlyTicks(chartData) + const tickFormatter = isShortRange ? formatChartWeekLabel : formatChartMonthYearLabel + + const handleModeChange = (nextMode: TPortfolioVaultGrowthChartMode): void => { + if (!mode) { + setUncontrolledMode(nextMode) + } + onModeChange?.(nextMode) + } + + return ( +
+ {title || showModeToggle ? ( +
+
+ {title ?

{title}

: null} + {showModeToggle ? ( +
+ {(['position', 'index'] as const).map((nextMode) => ( + + ))} +
+ ) : null} +
+ {showModeToggle ?

{MODE_COPY[activeMode]}

: null} +
+ ) : null} + + {!hasRenderableValue ? ( +
+

{emptyMessage}

+
+ ) : ( +
+ + + + + + ( + + )} + /> + {series.map((vaultSeries) => ( + + ))} + + +
+ )} +
+ ) +} diff --git a/src/components/pages/portfolio/hooks/buildVaultSuggestions.ts b/src/components/pages/portfolio/hooks/buildVaultSuggestions.ts index acc26ac9f..ef551b139 100644 --- a/src/components/pages/portfolio/hooks/buildVaultSuggestions.ts +++ b/src/components/pages/portfolio/hooks/buildVaultSuggestions.ts @@ -7,6 +7,7 @@ export type TVaultSuggestion = { vault: TKongVault externalProtocol: string underlyingSymbol: string + matchedChainID: number } export function buildVaultSuggestions( @@ -37,7 +38,14 @@ export function buildVaultSuggestions( const bestVault = bestVaultByUnderlying.get(normalized) if (!bestVault) return [] - return [{ vault: bestVault, externalProtocol: token.protocol, underlyingSymbol: token.underlyingSymbol }] + return [ + { + vault: bestVault, + externalProtocol: token.protocol, + underlyingSymbol: token.underlyingSymbol, + matchedChainID: token.chainId + } + ] }) .filter((suggestion) => { const vaultKey = getVaultKey(suggestion.vault) diff --git a/src/components/pages/portfolio/hooks/portfolioVisibility.test.ts b/src/components/pages/portfolio/hooks/portfolioVisibility.test.ts index cfeb80708..f0547ed4d 100644 --- a/src/components/pages/portfolio/hooks/portfolioVisibility.test.ts +++ b/src/components/pages/portfolio/hooks/portfolioVisibility.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { filterVisiblePortfolioHoldings } from './portfolioVisibility' +import { filterVisiblePortfolioHoldings, isPortfolioDustValueVisible } from './portfolioVisibility' function makeVault(address: string, isHidden: boolean) { return { @@ -104,4 +104,32 @@ describe('filterVisiblePortfolioHoldings', () => { expect(filterVisiblePortfolioHoldings([visible, hidden], true)).toEqual([visible, hidden]) }) + + it('hides dust vault positions when the dust guard is enabled', () => { + const dust = makeVault('0x1111111111111111111111111111111111111111', false) + const visible = makeVault('0x2222222222222222222222222222222222222222', false) + + expect( + filterVisiblePortfolioHoldings([dust, visible], false, { + shouldHideDust: true, + getVaultValue: (vault) => (vault === dust ? 0.009 : 0.01) + }) + ).toEqual([visible]) + }) + + it('keeps dust vault positions when the dust guard is disabled', () => { + const dust = makeVault('0x1111111111111111111111111111111111111111', false) + + expect( + filterVisiblePortfolioHoldings([dust], false, { + shouldHideDust: false, + getVaultValue: () => 0.009 + }) + ).toEqual([dust]) + }) + + it('treats one cent as visible portfolio value', () => { + expect(isPortfolioDustValueVisible(0.009, true)).toBe(false) + expect(isPortfolioDustValueVisible(0.01, true)).toBe(true) + }) }) diff --git a/src/components/pages/portfolio/hooks/portfolioVisibility.ts b/src/components/pages/portfolio/hooks/portfolioVisibility.ts index 944d683c5..d84512958 100644 --- a/src/components/pages/portfolio/hooks/portfolioVisibility.ts +++ b/src/components/pages/portfolio/hooks/portfolioVisibility.ts @@ -1,9 +1,28 @@ import { getVaultInfo, type TKongVaultInput } from '@pages/vaults/domain/kongVaultSelectors' -export function filterVisiblePortfolioHoldings(vaults: T[], showHiddenVaults: boolean): T[] { - if (showHiddenVaults) { - return vaults +export const PORTFOLIO_DUST_USD_THRESHOLD = 0.01 + +export function isPortfolioDustValueVisible(value: number, shouldHideDust: boolean): boolean { + return !shouldHideDust || value >= PORTFOLIO_DUST_USD_THRESHOLD +} + +export function filterVisiblePortfolioHoldings( + vaults: T[], + showHiddenVaults: boolean, + options?: { + shouldHideDust?: boolean + getVaultValue?: (vault: T) => number } +): T[] { + return vaults.filter((vault) => { + if (!showHiddenVaults && Boolean(getVaultInfo(vault)?.isHidden)) { + return false + } + + if (!options?.shouldHideDust) { + return true + } - return vaults.filter((vault) => !Boolean(getVaultInfo(vault)?.isHidden)) + return isPortfolioDustValueVisible(options.getVaultValue?.(vault) ?? 0, true) + }) } diff --git a/src/components/pages/portfolio/hooks/usePortfolioActivity.ts b/src/components/pages/portfolio/hooks/usePortfolioActivity.ts new file mode 100644 index 000000000..e51dafda1 --- /dev/null +++ b/src/components/pages/portfolio/hooks/usePortfolioActivity.ts @@ -0,0 +1,166 @@ +import { useWeb3 } from '@shared/contexts/useWeb3' +import { fetchWithSchema } from '@shared/hooks/useFetch' +import { useInfiniteQuery, useQuery } from '@tanstack/react-query' +import { useEffect, useState } from 'react' +import { + portfolioActivityFacetsResponseSchema, + portfolioActivityResponseSchema, + type TPortfolioActivityEntry, + type TPortfolioActivityTypeFilter +} from '../types/api' + +type TPortfolioActivityFilters = { + type?: TPortfolioActivityTypeFilter + chainId?: number | null + startTimestamp?: number | null + endTimestamp?: number | null +} + +const ACTIVITY_FACET_LIMIT_PER_SOURCE = 500 +const MAX_ACTIVITY_RETRIES = 3 +const DEFAULT_ACTIVITY_RETRY_DELAY = 1000 + +export function usePortfolioActivity(limit = 10, enabled = true, filters: TPortfolioActivityFilters = {}) { + const { address } = useWeb3() + const isEnabled = Boolean(address) && enabled + const type = filters.type ?? 'all' + const chainId = filters.chainId ?? null + const startTimestamp = filters.startTimestamp ?? null + const endTimestamp = filters.endTimestamp ?? null + const shouldFetchFacets = type === 'all' && chainId === null && startTimestamp === null && endTimestamp === null + const [facetOffsetPerSource, setFacetOffsetPerSource] = useState(0) + const [discoveredFacetChainIds, setDiscoveredFacetChainIds] = useState(null) + const [isFacetScanComplete, setIsFacetScanComplete] = useState(false) + + useEffect(() => { + setFacetOffsetPerSource(0) + setDiscoveredFacetChainIds(null) + setIsFacetScanComplete(false) + }, [address]) + + const shouldRetryActivityRequest = (failureCount: number, error: unknown): boolean => { + const status = (error as { response?: { status?: number }; status?: number })?.response?.status + const fallbackStatus = (error as { status?: number })?.status + const responseStatus = status ?? fallbackStatus + + if (responseStatus === 429) { + return failureCount < MAX_ACTIVITY_RETRIES + } + + if (typeof responseStatus === 'number' && responseStatus < 500) { + return false + } + + return failureCount < MAX_ACTIVITY_RETRIES + } + + const getActivityRetryDelay = (failureCount: number, error: unknown): number => { + const retryAfterMs = (error as { retryAfterMs?: number })?.retryAfterMs + if (typeof retryAfterMs === 'number') { + return retryAfterMs + } + + return Math.min(DEFAULT_ACTIVITY_RETRY_DELAY * 2 ** failureCount, 30000) + } + + const query = useInfiniteQuery({ + queryKey: ['portfolio-activity', address, limit, type, chainId, startTimestamp, endTimestamp], + enabled: isEnabled, + initialPageParam: 0, + queryFn: ({ pageParam }) => { + const params = new URLSearchParams({ + address: address ?? '', + limit: String(limit), + offset: String(Number(pageParam) || 0), + type + }) + + if (chainId !== null) { + params.set('chainId', String(chainId)) + } + + if (startTimestamp !== null) { + params.set('startTimestamp', String(startTimestamp)) + } + + if (endTimestamp !== null) { + params.set('endTimestamp', String(endTimestamp)) + } + + return fetchWithSchema(`/api/holdings/activity?${params}`, portfolioActivityResponseSchema, { + timeout: 30 * 1000 + }) + }, + getNextPageParam: (lastPage) => lastPage.pageInfo.nextOffset ?? undefined, + staleTime: 60 * 1000, + refetchOnWindowFocus: false, + retry: shouldRetryActivityRequest, + retryDelay: getActivityRetryDelay + }) + const hasLoadedFirstActivityPage = Boolean(query.data?.pages[0]) + + const facetsQuery = useQuery({ + queryKey: ['portfolio-activity-facets', address, 'all', facetOffsetPerSource], + enabled: isEnabled && shouldFetchFacets && hasLoadedFirstActivityPage && !isFacetScanComplete, + queryFn: () => { + const params = new URLSearchParams({ + address: address ?? '', + version: 'all', + limitPerSource: String(ACTIVITY_FACET_LIMIT_PER_SOURCE), + offsetPerSource: String(facetOffsetPerSource) + }) + + return fetchWithSchema(`/api/holdings/activity-facets?${params}`, portfolioActivityFacetsResponseSchema, { + timeout: 30 * 1000 + }) + }, + staleTime: 5 * 60 * 1000, + refetchOnWindowFocus: false, + retry: shouldRetryActivityRequest, + retryDelay: getActivityRetryDelay + }) + + useEffect(() => { + const page = facetsQuery.data + if (!page || !shouldFetchFacets) { + return + } + + setDiscoveredFacetChainIds((previousChainIds) => + Array.from(new Set([...(previousChainIds ?? []), ...page.facets.chainIds])).sort( + (firstChainId, secondChainId) => firstChainId - secondChainId + ) + ) + + if (page.pageInfo.nextOffsetPerSource !== null) { + setFacetOffsetPerSource(page.pageInfo.nextOffsetPerSource) + return + } + + setIsFacetScanComplete(true) + }, [facetsQuery.data, shouldFetchFacets]) + + const entries: TPortfolioActivityEntry[] = query.data?.pages.flatMap((page) => page.entries) ?? [] + const facetChainIds = discoveredFacetChainIds + const availableChainIds = + facetChainIds ?? + (shouldFetchFacets && query.data + ? Array.from(new Set(entries.map((entry) => entry.chainId))).sort( + (firstChainId, secondChainId) => firstChainId - secondChainId + ) + : null) + const isInitialLoading = query.isLoading || (query.isFetching && entries.length === 0) + const isEmpty = !isInitialLoading && !query.error && Boolean(address) && entries.length === 0 + const error = query.error instanceof Error ? query.error : query.error ? new Error('Failed to fetch activity') : null + + return { + data: entries, + availableChainIds, + isLoading: isInitialLoading, + isLoadingMore: query.isFetchingNextPage, + error, + isEmpty, + hasMore: Boolean(query.hasNextPage), + loadMore: () => query.fetchNextPage() + } +} diff --git a/src/components/pages/portfolio/hooks/usePortfolioBreakdown.ts b/src/components/pages/portfolio/hooks/usePortfolioBreakdown.ts new file mode 100644 index 000000000..afaf32ade --- /dev/null +++ b/src/components/pages/portfolio/hooks/usePortfolioBreakdown.ts @@ -0,0 +1,32 @@ +import { useWeb3 } from '@shared/contexts/useWeb3' +import { useFetch } from '@shared/hooks/useFetch' +import { useMemo } from 'react' +import { portfolioBreakdownResponseSchema, type TPortfolioBreakdownResponse } from '../types/api' + +export function usePortfolioBreakdown(date: string | null, enabled = true) { + const { address } = useWeb3() + + const endpoint = useMemo(() => { + if (!address || !date || !enabled) { + return null + } + + return `/api/holdings/breakdown?address=${address}&date=${date}&fetchType=parallel` + }, [address, date, enabled]) + + const { data, isLoading, isFetching, error } = useFetch({ + endpoint, + schema: portfolioBreakdownResponseSchema, + config: { + cacheDuration: 30 * 60 * 1000, + keepPreviousData: false, + timeout: 2 * 60 * 1000 + } + }) + + return { + data: data ?? null, + isLoading: isLoading || isFetching, + error + } +} diff --git a/src/components/pages/portfolio/hooks/usePortfolioEntryRefresh.helpers.test.ts b/src/components/pages/portfolio/hooks/usePortfolioEntryRefresh.helpers.test.ts new file mode 100644 index 000000000..b31e56b92 --- /dev/null +++ b/src/components/pages/portfolio/hooks/usePortfolioEntryRefresh.helpers.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest' +import { shouldRequestPortfolioEntryRefresh } from './usePortfolioEntryRefresh.helpers' + +describe('shouldRequestPortfolioEntryRefresh', () => { + it('refreshes once when the portfolio page is active and has not refreshed yet', () => { + expect(shouldRequestPortfolioEntryRefresh({ hasRequestedRefresh: false, isActive: true })).toBe(true) + }) + + it('does not refresh when the portfolio page is not active', () => { + expect(shouldRequestPortfolioEntryRefresh({ hasRequestedRefresh: false, isActive: false })).toBe(false) + }) + + it('does not refresh again after the page-entry refresh already ran', () => { + expect(shouldRequestPortfolioEntryRefresh({ hasRequestedRefresh: true, isActive: true })).toBe(false) + }) +}) diff --git a/src/components/pages/portfolio/hooks/usePortfolioEntryRefresh.helpers.ts b/src/components/pages/portfolio/hooks/usePortfolioEntryRefresh.helpers.ts new file mode 100644 index 000000000..e3c31dfd4 --- /dev/null +++ b/src/components/pages/portfolio/hooks/usePortfolioEntryRefresh.helpers.ts @@ -0,0 +1,9 @@ +export function shouldRequestPortfolioEntryRefresh({ + isActive, + hasRequestedRefresh +}: { + isActive: boolean + hasRequestedRefresh: boolean +}) { + return isActive && !hasRequestedRefresh +} diff --git a/src/components/pages/portfolio/hooks/usePortfolioEntryRefresh.ts b/src/components/pages/portfolio/hooks/usePortfolioEntryRefresh.ts new file mode 100644 index 000000000..be1f24efc --- /dev/null +++ b/src/components/pages/portfolio/hooks/usePortfolioEntryRefresh.ts @@ -0,0 +1,22 @@ +import { useEffect, useRef } from 'react' +import { shouldRequestPortfolioEntryRefresh } from './usePortfolioEntryRefresh.helpers' + +export function usePortfolioEntryRefresh({ + isActive, + onRefresh +}: { + isActive: boolean + onRefresh: () => Promise +}) { + const hasRequestedRefreshRef = useRef(false) + + useEffect(() => { + if (!shouldRequestPortfolioEntryRefresh({ isActive, hasRequestedRefresh: hasRequestedRefreshRef.current })) { + return + } + + hasRequestedRefreshRef.current = true + // Portfolio freshness depends on an imperative wallet refresh when the route becomes active. + void onRefresh().catch(() => undefined) + }, [isActive, onRefresh]) +} diff --git a/src/components/pages/portfolio/hooks/usePortfolioHistory.helpers.ts b/src/components/pages/portfolio/hooks/usePortfolioHistory.helpers.ts new file mode 100644 index 000000000..9669e7d18 --- /dev/null +++ b/src/components/pages/portfolio/hooks/usePortfolioHistory.helpers.ts @@ -0,0 +1,32 @@ +import type { + TPortfolioHistoryChartData, + TPortfolioHistoryDenomination, + TPortfolioLiveBalanceSnapshot +} from '../types/api' + +export function upsertLivePortfolioBalancePoint({ + data, + denomination, + liveSnapshot +}: { + data: TPortfolioHistoryChartData | null + denomination: TPortfolioHistoryDenomination + liveSnapshot: TPortfolioLiveBalanceSnapshot | null +}): TPortfolioHistoryChartData | null { + if (!data || !liveSnapshot) { + return data + } + + const liveValue = denomination === 'eth' ? liveSnapshot.totalEth : liveSnapshot.totalUsd + if (typeof liveValue !== 'number' || !Number.isFinite(liveValue)) { + return data + } + + const livePoint = { date: liveSnapshot.date, value: liveValue, isLive: true } + const existingIndex = data.findIndex((point) => point.date === liveSnapshot.date) + if (existingIndex === -1) { + return [...data, livePoint] + } + + return data.map((point, index) => (index === existingIndex ? livePoint : point)) +} diff --git a/src/components/pages/portfolio/hooks/usePortfolioHistory.test.ts b/src/components/pages/portfolio/hooks/usePortfolioHistory.test.ts new file mode 100644 index 000000000..e31f5a213 --- /dev/null +++ b/src/components/pages/portfolio/hooks/usePortfolioHistory.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest' +import type { TPortfolioHistoryChartData, TPortfolioLiveBalanceSnapshot } from '../types/api' +import { upsertLivePortfolioBalancePoint } from './usePortfolioHistory.helpers' + +const liveSnapshot: TPortfolioLiveBalanceSnapshot = { + date: '2026-05-14', + totalUsd: 1234, + totalEth: 0.4, + vaults: [ + { + key: '1/0x1111111111111111111111111111111111111111', + chainId: 1, + vaultAddress: '0x1111111111111111111111111111111111111111', + usdValue: 1234 + } + ] +} + +describe('upsertLivePortfolioBalancePoint', () => { + it('appends a live USD point when history ends before the live snapshot date', () => { + const data: TPortfolioHistoryChartData = [{ date: '2026-05-13', value: 1000 }] + + expect(upsertLivePortfolioBalancePoint({ data, denomination: 'usd', liveSnapshot })).toEqual([ + { date: '2026-05-13', value: 1000 }, + { date: '2026-05-14', value: 1234, isLive: true } + ]) + }) + + it('replaces an existing same-date point with the live value', () => { + const data: TPortfolioHistoryChartData = [ + { date: '2026-05-13', value: 1000 }, + { date: '2026-05-14', value: 1100 } + ] + + expect(upsertLivePortfolioBalancePoint({ data, denomination: 'usd', liveSnapshot })).toEqual([ + { date: '2026-05-13', value: 1000 }, + { date: '2026-05-14', value: 1234, isLive: true } + ]) + }) + + it('uses the ETH live total for ETH-denominated history', () => { + const data: TPortfolioHistoryChartData = [{ date: '2026-05-13', value: 0.35 }] + + expect(upsertLivePortfolioBalancePoint({ data, denomination: 'eth', liveSnapshot })).toEqual([ + { date: '2026-05-13', value: 0.35 }, + { date: '2026-05-14', value: 0.4, isLive: true } + ]) + }) + + it('keeps ETH history unchanged when the live ETH total is unavailable', () => { + const data: TPortfolioHistoryChartData = [{ date: '2026-05-13', value: 0.35 }] + const snapshot = { ...liveSnapshot, totalEth: null } + + expect(upsertLivePortfolioBalancePoint({ data, denomination: 'eth', liveSnapshot: snapshot })).toBe(data) + }) + + it('keeps history unchanged when the live snapshot is unavailable', () => { + const data: TPortfolioHistoryChartData = [{ date: '2026-05-13', value: 1000 }] + + expect(upsertLivePortfolioBalancePoint({ data, denomination: 'usd', liveSnapshot: null })).toBe(data) + }) +}) diff --git a/src/components/pages/portfolio/hooks/usePortfolioHistory.ts b/src/components/pages/portfolio/hooks/usePortfolioHistory.ts new file mode 100644 index 000000000..a163d3001 --- /dev/null +++ b/src/components/pages/portfolio/hooks/usePortfolioHistory.ts @@ -0,0 +1,89 @@ +import { useWeb3 } from '@shared/contexts/useWeb3' +import { useFetch } from '@shared/hooks/useFetch' +import { useMemo } from 'react' +import type { + TPortfolioHistoryChartData, + TPortfolioHistoryDenomination, + TPortfolioHistorySimpleResponse, + TPortfolioHistoryTimeframe, + TPortfolioLiveBalanceSnapshot +} from '../types/api' +import { portfolioHistorySimpleResponseSchema } from '../types/api' +import { upsertLivePortfolioBalancePoint } from './usePortfolioHistory.helpers' +import { createPortfolioHistoryProgressId, usePortfolioHistoryProgress } from './usePortfolioHistoryProgress' + +const PORTFOLIO_HISTORY_CACHE_DURATION = 60 * 60 * 1000 + +export function usePortfolioHistory( + denomination: TPortfolioHistoryDenomination = 'usd', + timeframe: TPortfolioHistoryTimeframe = '1y', + enabled = true, + liveSnapshot: TPortfolioLiveBalanceSnapshot | null = null +) { + const { address } = useWeb3() + const progressId = useMemo( + () => + address && enabled ? createPortfolioHistoryProgressId(['portfolio-history', denomination, timeframe]) : null, + [address, denomination, enabled, timeframe] + ) + + const endpoint = useMemo(() => { + if (!address || !enabled || !progressId) { + return null + } + return `/api/holdings/history?address=${address}&denomination=${denomination}&timeframe=${timeframe}&fetchType=parallel&progressId=${encodeURIComponent(progressId)}` + }, [address, denomination, enabled, progressId, timeframe]) + const cacheKey = useMemo( + () => + address && enabled ? ['fetch', 'portfolio-history', address.toLowerCase(), denomination, timeframe] : undefined, + [address, denomination, enabled, timeframe] + ) + + const { + data: rawData, + isLoading, + isFetching, + error + } = useFetch({ + endpoint, + schema: portfolioHistorySimpleResponseSchema, + config: { + cacheKey, + cacheDuration: PORTFOLIO_HISTORY_CACHE_DURATION, + gcTime: PORTFOLIO_HISTORY_CACHE_DURATION, + keepPreviousData: false, + timeout: 2 * 60 * 1000 // 2 minutes for large holdings requests + } + }) + + const data = useMemo(() => { + if (!rawData?.dataPoints) { + return null + } + + const historicalData = rawData.dataPoints.map((point) => ({ + date: point.date, + value: point.value + })) + return upsertLivePortfolioBalancePoint({ data: historicalData, denomination, liveSnapshot }) + }, [denomination, liveSnapshot, rawData]) + + const hasData = Boolean(rawData?.dataPoints) + const isLoadingState = !hasData && (isLoading || isFetching) + const errorStatus = + (error as { response?: { status?: number }; status?: number } | null)?.response?.status ?? + (error as { status?: number } | null)?.status + const isEmpty = !isLoadingState && Boolean(address) && (errorStatus === 404 || Boolean(data && data.length === 0)) + const visibleError = isEmpty ? null : error + const progress = usePortfolioHistoryProgress(progressId, isLoadingState) + + return { + data, + denomination, + timeframe, + isLoading: isLoadingState, + progress, + error: visibleError, + isEmpty + } +} diff --git a/src/components/pages/portfolio/hooks/usePortfolioHistoryProgress.ts b/src/components/pages/portfolio/hooks/usePortfolioHistoryProgress.ts new file mode 100644 index 000000000..8d927a78e --- /dev/null +++ b/src/components/pages/portfolio/hooks/usePortfolioHistoryProgress.ts @@ -0,0 +1,69 @@ +import { useQuery } from '@tanstack/react-query' +import { useMemo } from 'react' +import { portfolioHistoryProgressResponseSchema, type TPortfolioHistoryProgressResponse } from '../types/api' + +export type TPortfolioHistoryProgress = Pick< + TPortfolioHistoryProgressResponse, + 'status' | 'progress' | 'message' | 'detail' +> + +function getLatestLogDetail(progress: TPortfolioHistoryProgressResponse): string | null { + const latestLog = progress.logs.at(-1) + if (!latestLog) { + return null + } + + return latestLog.message +} + +export function createPortfolioHistoryProgressId(parts: string[]): string { + const randomValue = + globalThis.crypto?.randomUUID?.() ?? `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}` + return [...parts, randomValue] + .join(':') + .replace(/[^a-zA-Z0-9:_-]/g, '-') + .slice(0, 160) +} + +export function usePortfolioHistoryProgress( + progressId: string | null, + enabled: boolean +): TPortfolioHistoryProgress | null { + const endpoint = useMemo( + () => (progressId ? `/api/holdings/progress?id=${encodeURIComponent(progressId)}` : null), + [progressId] + ) + + const query = useQuery({ + queryKey: ['portfolio-history-progress', progressId], + enabled: Boolean(endpoint) && enabled, + queryFn: async () => { + const response = await globalThis.fetch(endpoint as string, { headers: { Accept: 'application/json' } }) + if (response.status === 404) { + return null + } + if (!response.ok) { + throw new Error(`HTTP error: ${response.status}`) + } + const data = await response.json() + const parsed = portfolioHistoryProgressResponseSchema.safeParse(data) + if (!parsed.success) { + throw new Error('Progress schema validation failed') + } + return parsed.data + }, + refetchInterval: enabled ? 1000 : false, + refetchOnWindowFocus: false, + retry: false, + staleTime: 0 + }) + + return query.data + ? { + status: query.data.status, + progress: query.data.progress, + message: query.data.message, + detail: query.data.detail ?? getLatestLogDetail(query.data) + } + : null +} diff --git a/src/components/pages/portfolio/hooks/usePortfolioModel.helpers.test.ts b/src/components/pages/portfolio/hooks/usePortfolioModel.helpers.test.ts new file mode 100644 index 000000000..a082aa0db --- /dev/null +++ b/src/components/pages/portfolio/hooks/usePortfolioModel.helpers.test.ts @@ -0,0 +1,19 @@ +import { YVUSD_CHAIN_ID, YVUSD_LOCKED_ADDRESS, YVUSD_UNLOCKED_ADDRESS } from '@pages/vaults/utils/yvUsd' +import { describe, expect, it } from 'vitest' +import { hasYvUsdPortfolioHoldings } from './usePortfolioModel.helpers' + +const getVaultKey = (address: string): string => `${YVUSD_CHAIN_ID}_${address}` + +describe('hasYvUsdPortfolioHoldings', () => { + it('detects unlocked yvUSD holdings', () => { + expect(hasYvUsdPortfolioHoldings(new Set([getVaultKey(YVUSD_UNLOCKED_ADDRESS)]))).toBe(true) + }) + + it('detects locked yvUSD holdings', () => { + expect(hasYvUsdPortfolioHoldings(new Set([getVaultKey(YVUSD_LOCKED_ADDRESS)]))).toBe(true) + }) + + it('ignores unrelated vault holdings', () => { + expect(hasYvUsdPortfolioHoldings(new Set([getVaultKey('0x0000000000000000000000000000000000000001')]))).toBe(false) + }) +}) diff --git a/src/components/pages/portfolio/hooks/usePortfolioModel.helpers.ts b/src/components/pages/portfolio/hooks/usePortfolioModel.helpers.ts new file mode 100644 index 000000000..3faf99800 --- /dev/null +++ b/src/components/pages/portfolio/hooks/usePortfolioModel.helpers.ts @@ -0,0 +1,12 @@ +import { YVUSD_CHAIN_ID, YVUSD_LOCKED_ADDRESS, YVUSD_UNLOCKED_ADDRESS } from '@pages/vaults/utils/yvUsd' +import { toAddress } from '@shared/utils' + +function getChainAddressKey(chainID: number | undefined, address: string): string { + return `${chainID}_${toAddress(address)}` +} + +export function hasYvUsdPortfolioHoldings(holdingsKeySet: Set): boolean { + return [YVUSD_UNLOCKED_ADDRESS, YVUSD_LOCKED_ADDRESS].some((address) => + holdingsKeySet.has(getChainAddressKey(YVUSD_CHAIN_ID, address)) + ) +} diff --git a/src/components/pages/portfolio/hooks/usePortfolioModel.ts b/src/components/pages/portfolio/hooks/usePortfolioModel.ts index fdfcd45dd..0f5281877 100644 --- a/src/components/pages/portfolio/hooks/usePortfolioModel.ts +++ b/src/components/pages/portfolio/hooks/usePortfolioModel.ts @@ -1,6 +1,8 @@ +import { normalizeSymbol } from '@pages/portfolio/hooks/getEligibleVaults' import { useTokenSuggestions } from '@pages/portfolio/hooks/useTokenSuggestions' import { useVaultSuggestions } from '@pages/portfolio/hooks/useVaultSuggestions' import { KATANA_CHAIN_ID } from '@pages/vaults/constants/addresses' +import { useAppSettings } from '@pages/vaults/contexts/useAppSettings' import { getVaultAddress, getVaultChainID, @@ -32,9 +34,12 @@ import { useYearn } from '@shared/contexts/useYearn' import { getVaultKey, isV3Vault, type TVaultFlags } from '@shared/hooks/useVaultFilterUtils' import type { TSortDirection } from '@shared/types' import { isZeroAddress, toAddress } from '@shared/utils' +import { ETH_TOKEN_ADDRESS } from '@shared/utils/constants' import { calculateVaultEstimatedAPY, calculateVaultHistoricalAPY } from '@shared/utils/vaultApy' import { useCallback, useMemo, useState } from 'react' +import type { TPortfolioLiveBalanceSnapshot } from '../types/api' import { filterVisiblePortfolioHoldings } from './portfolioVisibility' +import { hasYvUsdPortfolioHoldings } from './usePortfolioModel.helpers' type THoldingsRow = { key: string @@ -43,8 +48,15 @@ type THoldingsRow = { } export type TSuggestedItem = - | { type: 'external'; key: string; vault: TKongVault; externalProtocol: string; underlyingSymbol: string } - | { type: 'personalized'; key: string; vault: TKongVault; matchedSymbol: string } + | { + type: 'external' + key: string + vault: TKongVault + externalProtocol: string + underlyingSymbol: string + matchedChainID: number + } + | { type: 'personalized'; key: string; vault: TKongVault; matchedSymbol: string; matchedChainID: number } | { type: 'generic'; key: string; vault: TKongVault } export type TPortfolioBlendedMetrics = { @@ -65,6 +77,7 @@ export type TPortfolioModel = { sortBy: TPossibleSortBy sortDirection: TSortDirection suggestedRows: TSuggestedItem[] + liveBalanceSnapshot: TPortfolioLiveBalanceSnapshot | null totalPortfolioValue: number vaultFlags: Record setSortBy: TSortStateSetter @@ -78,6 +91,30 @@ type TYvUsdPortfolioPosition = { combinedValue: number hasHoldings: boolean } +type TStablecoinHoldingMatch = { symbol: string; chainID: number } | null + +const STABLECOIN_SUGGESTION_SYMBOLS = new Set([ + 'AUSD', + 'BOLD', + 'CRVUSD', + 'DAI', + 'DOLA', + 'FRAX', + 'GHO', + 'LUSD', + 'MIM', + 'PYUSD', + 'SDAI', + 'SUSDE', + 'TUSD', + 'USDC', + 'USDD', + 'USDE', + 'USDP', + 'USDS', + 'USDT', + 'USD0' +]) function getLatestYvUsdHistoricalApyValue( apyData: ReturnType['apyData'], @@ -110,19 +147,43 @@ function getPortfolioRowHref(vault: TKongVaultInput): string | undefined { return `/vaults/${getVaultChainID(vault)}/${toAddress(getVaultAddress(vault))}` } +function getStablecoinHoldingMatch(balances: ReturnType['balances']): TStablecoinHoldingMatch { + return ( + Object.entries(balances ?? {}) + .flatMap(([chainIDKey, perChain]) => + Object.values(perChain ?? {}) + .filter((token) => { + const symbol = normalizeSymbol(token?.symbol ?? '') + return Boolean(token?.balance && token.balance.raw > 0n && STABLECOIN_SUGGESTION_SYMBOLS.has(symbol)) + }) + .map((token) => { + const parsedChainID = Number(chainIDKey) + return { + chainID: Number.isFinite(parsedChainID) ? parsedChainID : token.chainID, + symbol: normalizeSymbol(token.symbol), + value: token.value + } + }) + ) + .sort((a, b) => b.value - a.value)[0] ?? null + ) +} + export function usePortfolioModel(): TPortfolioModel { const { cumulatedValueInV2Vaults, cumulatedValueInV3Vaults, isLoading: isWalletLoading, + hasCompletedBalanceLoad, getBalance, getVaultHoldingsUsd, balances } = useWallet() const { isActive, openLoginModal, isUserConnecting, isIdentityLoading } = useWeb3() - const { vaults, allVaults, isLoadingVaultList } = useYearn() + const { vaults, allVaults, isLoadingVaultList, getPrice } = useYearn() const { listVault: yvUsdVault, unlockedVault: yvUsdUnlockedVault, lockedVault: yvUsdLockedVault } = useYvUsdVaults() const { apyData: yvUsdHistoricalApyData } = useYvUsdCharts() + const { shouldHideDust } = useAppSettings() const showHiddenVaults = usePersistedShowHiddenVaults() const [sortBy, setSortBy] = useState('deposited') const [sortDirection, setSortDirection] = useState('desc') @@ -222,9 +283,48 @@ export function usePortfolioModel(): TPortfolioModel { return result }, [balances, vaultLookup, yvUsdPosition.hasHoldings, yvUsdVault]) + const getVaultEstimatedAPY = useCallback( + (vault: (typeof holdingsVaults)[number]): number | null => { + if (isYvUsdVault(vault)) { + return yvUsdPosition.blendedCurrentApy + } + + const apy = calculateVaultEstimatedAPY(vault) + const hasHistoricalNet = 'performance' in vault && Boolean(vault.performance?.historical?.net) + return apy === 0 && !hasHistoricalNet ? null : apy + }, + [yvUsdPosition.blendedCurrentApy] + ) + + const getVaultHistoricalAPY = useCallback( + (vault: (typeof holdingsVaults)[number]): number | null => { + if (isYvUsdVault(vault)) { + return yvUsdPosition.blendedHistoricalApy + } + + return calculateVaultHistoricalAPY(vault) + }, + [yvUsdPosition.blendedHistoricalApy] + ) + + const getVaultValue = useCallback( + (vault: (typeof holdingsVaults)[number]): number => { + if (isYvUsdVault(vault)) { + return yvUsdPosition.combinedValue + } + + return getVaultHoldingsUsd(vault) + }, + [getVaultHoldingsUsd, yvUsdPosition.combinedValue] + ) + const visibleHoldingsVaults = useMemo( - () => filterVisiblePortfolioHoldings(holdingsVaults, showHiddenVaults), - [holdingsVaults, showHiddenVaults] + () => + filterVisiblePortfolioHoldings(holdingsVaults, showHiddenVaults, { + shouldHideDust, + getVaultValue + }), + [getVaultValue, holdingsVaults, shouldHideDust, showHiddenVaults] ) const vaultFlags = useMemo(() => { @@ -246,7 +346,7 @@ export function usePortfolioModel(): TPortfolioModel { const isSearchingBalances = (isActive || isUserConnecting) && (isWalletLoading || isUserConnecting || isIdentityLoading) - const isHoldingsLoading = (isLoadingVaultList && isActive) || isSearchingBalances + const isHoldingsLoading = (isLoadingVaultList && isActive) || isSearchingBalances || !hasCompletedBalanceLoad const suggestedVaultCandidates = useMemo( () => @@ -274,6 +374,8 @@ export function usePortfolioModel(): TPortfolioModel { () => sortedCandidates.filter((vault) => !holdingsKeySet.has(getVaultKey(vault))).slice(0, 4), [sortedCandidates, holdingsKeySet] ) + const yvUsdSuggestedVault = allVaults[YVUSD_LOCKED_ADDRESS] ?? allVaults[YVUSD_UNLOCKED_ADDRESS] + const stablecoinHoldingMatch = useMemo(() => getStablecoinHoldingMatch(balances), [balances]) const tokenSuggestions = useTokenSuggestions(holdingsKeySet) const { suggestions: vaultSuggestions } = useVaultSuggestions(holdingsKeySet) @@ -289,14 +391,38 @@ export function usePortfolioModel(): TPortfolioModel { ) const suggestedRows = useMemo((): TSuggestedItem[] => { + const yvUsdSuggestedVaultKey = yvUsdSuggestedVault ? getVaultKey(yvUsdSuggestedVault) : null + const hasYvUsdHoldings = hasYvUsdPortfolioHoldings(holdingsKeySet) + const yvUsdSuggestion = + yvUsdSuggestedVault && yvUsdSuggestedVaultKey && !hasYvUsdHoldings + ? { + item: stablecoinHoldingMatch + ? { + type: 'personalized' as const, + key: `pers-${yvUsdSuggestedVaultKey}-${stablecoinHoldingMatch.symbol.toLowerCase()}`, + vault: yvUsdSuggestedVault, + matchedSymbol: stablecoinHoldingMatch.symbol, + matchedChainID: stablecoinHoldingMatch.chainID + } + : { + type: 'generic' as const, + key: `gen-${yvUsdSuggestedVaultKey}`, + vault: yvUsdSuggestedVault + }, + vaultKey: yvUsdSuggestedVaultKey + } + : null + const candidates: { item: TSuggestedItem; vaultKey: string }[] = [ + ...(yvUsdSuggestion ? [yvUsdSuggestion] : []), ...vaultSuggestions.slice(0, 2).map((ext) => ({ item: { type: 'external' as const, key: `ext-${getVaultKey(ext.vault)}`, vault: ext.vault, externalProtocol: ext.externalProtocol, - underlyingSymbol: ext.underlyingSymbol + underlyingSymbol: ext.underlyingSymbol, + matchedChainID: ext.matchedChainID }, vaultKey: getVaultKey(ext.vault) })), @@ -305,7 +431,8 @@ export function usePortfolioModel(): TPortfolioModel { type: 'personalized' as const, key: `pers-${getVaultKey(ps.vault)}`, vault: ps.vault, - matchedSymbol: ps.matchedSymbol + matchedSymbol: ps.matchedSymbol, + matchedChainID: ps.matchedChainID }, vaultKey: getVaultKey(ps.vault) })), @@ -324,54 +451,44 @@ export function usePortfolioModel(): TPortfolioModel { }) .slice(0, 4) .map(({ item }) => item) - }, [vaultSuggestions, tokenSuggestions, genericVaults]) + }, [vaultSuggestions, tokenSuggestions, genericVaults, yvUsdSuggestedVault, holdingsKeySet, stablecoinHoldingMatch]) const hasHoldings = sortedHoldings.length > 0 const hasKatanaHoldings = useMemo( - () => holdingsVaults.some((vault) => getVaultChainID(vault) === KATANA_CHAIN_ID), - [holdingsVaults] + () => sortedHoldings.some((vault) => getVaultChainID(vault) === KATANA_CHAIN_ID), + [sortedHoldings] ) const totalPortfolioValue = (cumulatedValueInV2Vaults || 0) + (cumulatedValueInV3Vaults || 0) + const ethPrice = getPrice({ address: ETH_TOKEN_ADDRESS, chainID: 1 }).normalized - const getVaultEstimatedAPY = useCallback( - (vault: (typeof holdingsVaults)[number]): number | null => { - if (isYvUsdVault(vault)) { - return yvUsdPosition.blendedCurrentApy - } - - const apy = calculateVaultEstimatedAPY(vault) - const hasHistoricalNet = 'performance' in vault && Boolean(vault.performance?.historical?.net) - return apy === 0 && !hasHistoricalNet ? null : apy - }, - [yvUsdPosition.blendedCurrentApy] - ) - - const getVaultHistoricalAPY = useCallback( - (vault: (typeof holdingsVaults)[number]): number | null => { - if (isYvUsdVault(vault)) { - return yvUsdPosition.blendedHistoricalApy - } - - return calculateVaultHistoricalAPY(vault) - }, - [yvUsdPosition.blendedHistoricalApy] - ) + const liveBalanceSnapshot = useMemo(() => { + if (isHoldingsLoading || !Number.isFinite(totalPortfolioValue)) { + return null + } - const getVaultValue = useCallback( - (vault: (typeof holdingsVaults)[number]): number => { - if (isYvUsdVault(vault)) { - return yvUsdPosition.combinedValue - } + const totalEth = Number.isFinite(ethPrice) && ethPrice > 0 ? totalPortfolioValue / ethPrice : null + const date = new Date().toISOString().slice(0, 10) + const vaultValues = sortedHoldings + .map((vault) => ({ + key: getVaultKey(vault), + chainId: getVaultChainID(vault), + vaultAddress: toAddress(getVaultAddress(vault)), + usdValue: getVaultValue(vault) + })) + .filter((vault) => Number.isFinite(vault.usdValue)) - return getVaultHoldingsUsd(vault) - }, - [getVaultHoldingsUsd, yvUsdPosition.combinedValue] - ) + return { + date, + totalUsd: totalPortfolioValue, + totalEth, + vaults: vaultValues + } + }, [ethPrice, getVaultValue, isHoldingsLoading, sortedHoldings, totalPortfolioValue]) const blendedMetrics = useMemo(() => { const isFiniteNumber = (v: number | null): v is number => v !== null && Number.isFinite(v) - const { totalValue, weightedCurrent, weightedHistorical, hasCurrent, hasHistorical } = holdingsVaults.reduce( + const { totalValue, weightedCurrent, weightedHistorical, hasCurrent, hasHistorical } = sortedHoldings.reduce( (acc, vault) => { const value = getVaultValue(vault) if (!Number.isFinite(value) || value <= 0) return acc @@ -396,7 +513,7 @@ export function usePortfolioModel(): TPortfolioModel { totalValue > 0 && hasCurrent ? totalPortfolioValue * (weightedCurrent / totalValue) : null return { blendedCurrentAPY, blendedHistoricalAPY, estimatedAnnualReturn } - }, [getVaultEstimatedAPY, getVaultHistoricalAPY, getVaultValue, holdingsVaults, totalPortfolioValue]) + }, [getVaultEstimatedAPY, getVaultHistoricalAPY, getVaultValue, sortedHoldings, totalPortfolioValue]) return { blendedMetrics, @@ -410,6 +527,7 @@ export function usePortfolioModel(): TPortfolioModel { sortBy, sortDirection, suggestedRows, + liveBalanceSnapshot, totalPortfolioValue, vaultFlags, setSortBy, diff --git a/src/components/pages/portfolio/hooks/usePortfolioProtocolReturnHistory.ts b/src/components/pages/portfolio/hooks/usePortfolioProtocolReturnHistory.ts new file mode 100644 index 000000000..8c817ef5e --- /dev/null +++ b/src/components/pages/portfolio/hooks/usePortfolioProtocolReturnHistory.ts @@ -0,0 +1,85 @@ +import { useWeb3 } from '@shared/contexts/useWeb3' +import { useFetch } from '@shared/hooks/useFetch' +import { useMemo } from 'react' +import { + portfolioProtocolReturnHistoryResponseSchema, + type TPortfolioHistoryTimeframe, + type TPortfolioProtocolReturnHistoryChartData, + type TPortfolioProtocolReturnHistoryFamilySeries, + type TPortfolioProtocolReturnHistoryResponse, + type TPortfolioProtocolReturnHistorySummary +} from '../types/api' +import { createPortfolioHistoryProgressId, usePortfolioHistoryProgress } from './usePortfolioHistoryProgress' + +const PORTFOLIO_HISTORY_CACHE_DURATION = 60 * 60 * 1000 + +export function usePortfolioProtocolReturnHistory(timeframe: TPortfolioHistoryTimeframe = '1y', enabled = true) { + const { address } = useWeb3() + const progressId = useMemo( + () => (address && enabled ? createPortfolioHistoryProgressId(['portfolio-protocol-history', timeframe]) : null), + [address, enabled, timeframe] + ) + + const endpoint = useMemo(() => { + if (!address || !enabled || !progressId) { + return null + } + + return `/api/holdings/protocol-return/history?address=${address}&timeframe=${timeframe}&fetchType=parallel&progressId=${encodeURIComponent(progressId)}` + }, [address, enabled, progressId, timeframe]) + const cacheKey = useMemo( + () => (address && enabled ? ['fetch', 'portfolio-protocol-history', address.toLowerCase(), timeframe] : undefined), + [address, enabled, timeframe] + ) + + const { data, isLoading, isFetching, error } = useFetch({ + endpoint, + schema: portfolioProtocolReturnHistoryResponseSchema, + config: { + cacheKey, + cacheDuration: PORTFOLIO_HISTORY_CACHE_DURATION, + gcTime: PORTFOLIO_HISTORY_CACHE_DURATION, + keepPreviousData: false, + timeout: 2 * 60 * 1000 + } + }) + + const history = useMemo(() => { + if (!data?.dataPoints) { + return null + } + + return data.dataPoints.map((point) => ({ + date: point.date, + growthWeightUsd: point.growthWeightUsd, + growthWeightEth: point.growthWeightEth, + protocolReturnPct: point.protocolReturnPct, + annualizedProtocolReturnPct: point.annualizedProtocolReturnPct, + growthIndex: point.growthIndex + })) + }, [data]) + + const summary = useMemo(() => data?.summary ?? null, [data]) + const familySeries = useMemo(() => data?.familySeries ?? [], [data]) + + const hasData = Boolean(data?.dataPoints) + const isLoadingState = !hasData && (isLoading || isFetching) + const errorStatus = + (error as { response?: { status?: number }; status?: number } | null)?.response?.status ?? + (error as { status?: number } | null)?.status + const isEmpty = + !isLoadingState && Boolean(address) && (errorStatus === 404 || Boolean(history && history.length === 0)) + const visibleError = isEmpty ? null : error + const progress = usePortfolioHistoryProgress(progressId, isLoadingState) + + return { + data: history, + summary, + familySeries, + timeframe, + isLoading: isLoadingState, + progress, + error: visibleError, + isEmpty + } +} diff --git a/src/components/pages/portfolio/hooks/useTokenSuggestions.ts b/src/components/pages/portfolio/hooks/useTokenSuggestions.ts index e9e57855c..eb9164a45 100644 --- a/src/components/pages/portfolio/hooks/useTokenSuggestions.ts +++ b/src/components/pages/portfolio/hooks/useTokenSuggestions.ts @@ -8,6 +8,7 @@ import { useMemo } from 'react' export type TTokenSuggestion = { vault: TKongVault matchedSymbol: string + matchedChainID: number } export function useTokenSuggestions(holdingsKeySet: Set): TTokenSuggestion[] { @@ -15,19 +16,26 @@ export function useTokenSuggestions(holdingsKeySet: Set): TTokenSuggesti const { vaults } = useYearn() return useMemo(() => { - const userTokens = Object.values(balances ?? {}).flatMap((perChain) => - Object.values(perChain ?? {}).filter( - (token) => token?.balance && token.balance.raw > 0n && token.symbol && token.value > 1 - ) + const userTokens = Object.entries(balances ?? {}).flatMap(([chainIDKey, perChain]) => + Object.values(perChain ?? {}) + .filter((token) => token?.balance && token.balance.raw > 0n && token.symbol && token.value > 1) + .map((token) => { + const parsedChainID = Number(chainIDKey) + return { token, chainID: Number.isFinite(parsedChainID) ? parsedChainID : token.chainID } + }) ) - const symbolTotals = userTokens.reduce((acc, { symbol, value }) => { - const normalized = normalizeSymbol(symbol) + const symbolTotals = userTokens.reduce((acc, { token, chainID }) => { + const normalized = normalizeSymbol(token.symbol) if (!normalized) return acc - return acc.set(normalized, (acc.get(normalized) ?? 0) + value) - }, new Map()) + const previous = acc.get(normalized) + const totalValue = (previous?.totalValue ?? 0) + token.value + const chainValues = new Map(previous?.chainValues ?? []) + chainValues.set(chainID, (chainValues.get(chainID) ?? 0) + token.value) + return acc.set(normalized, { totalValue, chainValues }) + }, new Map }>()) - const sortedSymbols = [...symbolTotals.entries()].sort((a, b) => b[1] - a[1]) + const sortedSymbols = [...symbolTotals.entries()].sort((a, b) => b[1].totalValue - a[1].totalValue) const eligible = getEligibleVaults(vaults, holdingsKeySet) @@ -38,18 +46,19 @@ export function useTokenSuggestions(holdingsKeySet: Set): TTokenSuggesti }, new Map()) return sortedSymbols.reduce<{ results: TTokenSuggestion[]; usedVaults: Set }>( - (acc, [symbol]) => { + (acc, [symbol, { chainValues }]) => { if (acc.results.length >= 4) return acc const candidates = vaultsBySymbol.get(symbol) if (!candidates?.length) return acc const bestVault = selectPreferredVault(candidates.filter((vault) => !acc.usedVaults.has(getVaultKey(vault)))) + const matchedChainID = [...chainValues.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] - if (!bestVault) return acc + if (!bestVault || !matchedChainID) return acc acc.usedVaults.add(getVaultKey(bestVault)) return { - results: [...acc.results, { vault: bestVault, matchedSymbol: symbol }], + results: [...acc.results, { vault: bestVault, matchedSymbol: symbol, matchedChainID }], usedVaults: acc.usedVaults } }, diff --git a/src/components/pages/portfolio/index.tsx b/src/components/pages/portfolio/index.tsx index bf5a797af..ef72f5ed4 100644 --- a/src/components/pages/portfolio/index.tsx +++ b/src/components/pages/portfolio/index.tsx @@ -1,9 +1,23 @@ +import { + Dialog, + Listbox, + ListboxButton, + ListboxOption, + ListboxOptions, + Transition, + TransitionChild +} from '@headlessui/react' import { usePlausible } from '@hooks/usePlausible' +import { mergeChainMerkleData } from '@pages/portfolio/claimRewards.helpers' import { EmptySectionCard } from '@pages/portfolio/components/EmptySectionCard' +import { usePortfolioEntryRefresh } from '@pages/portfolio/hooks/usePortfolioEntryRefresh' import { type TPortfolioModel, usePortfolioModel } from '@pages/portfolio/hooks/usePortfolioModel' import { useVaultWithStakingRewards } from '@pages/portfolio/hooks/useVaultWithStakingRewards' +import { type TVaultsChainButton, VaultsChainSelector } from '@pages/vaults/components/filters/VaultsChainSelector' +import { VaultsListChip } from '@pages/vaults/components/list/VaultsListChip' import { VaultsListHead } from '@pages/vaults/components/list/VaultsListHead' import { VaultsListRow } from '@pages/vaults/components/list/VaultsListRow' +import { VirtualizedVaultsList } from '@pages/vaults/components/list/VirtualizedVaultsList' import { Notification } from '@pages/vaults/components/notifications/Notification' import { SuggestedVaultCard } from '@pages/vaults/components/SuggestedVaultCard' import { MerkleRewardRow } from '@pages/vaults/components/widget/rewards/MerkleRewardRow' @@ -13,29 +27,76 @@ import { TransactionOverlay, type TransactionStep } from '@pages/vaults/componen import { getVaultAddress, getVaultChainID, + getVaultName, getVaultStaking, + getVaultSymbol, + getVaultToken, type TKongVault } from '@pages/vaults/domain/kongVaultSelectors' import { useMerkleRewards } from '@pages/vaults/hooks/rewards/useMerkleRewards' import { useStakingRewards } from '@pages/vaults/hooks/rewards/useStakingRewards' import type { TPossibleSortBy } from '@pages/vaults/hooks/useSortVaults' +import { resolveNextSingleChainSelection } from '@pages/vaults/utils/chainSelection' import { Breadcrumbs } from '@shared/components/Breadcrumbs' -import { METRIC_VALUE_CLASS, MetricHeader, MetricsCard, type TMetricBlock } from '@shared/components/MetricsCard' +import { METRIC_VALUE_CLASS, MetricHeader, type TMetricBlock } from '@shared/components/MetricsCard' +import { SearchBar } from '@shared/components/SearchBar' import { SwitchChainPrompt } from '@shared/components/SwitchChainPrompt' +import { TokenLogo } from '@shared/components/TokenLogo' import { Tooltip } from '@shared/components/Tooltip' +import { YearnLogoSpinner } from '@shared/components/YearnLogoSpinner' import { useNotifications } from '@shared/contexts/useNotifications' +import { useWallet } from '@shared/contexts/useWallet' import { useWeb3 } from '@shared/contexts/useWeb3' import { useYearn } from '@shared/contexts/useYearn' +import { useTokenList } from '@shared/contexts/WithTokenList' import { useChainId, useSwitchChain } from '@shared/hooks/useAppWagmi' +import { useChainOptions } from '@shared/hooks/useChains' import { getVaultKey } from '@shared/hooks/useVaultFilterUtils' +import { IconCalendarDays } from '@shared/icons/IconCalendarDays' +import { IconCheck } from '@shared/icons/IconCheck' +import { IconChevron } from '@shared/icons/IconChevron' +import { IconCopy } from '@shared/icons/IconCopy' +import { IconCross } from '@shared/icons/IconCross' +import { IconDeposit } from '@shared/icons/IconDeposit' +import { IconGitCompare } from '@shared/icons/IconGitCompare' +import { IconHandCoins } from '@shared/icons/IconHandCoins' +import { IconLinkOut } from '@shared/icons/IconLinkOut' +import { IconSearch } from '@shared/icons/IconSearch' import { IconSpinner } from '@shared/icons/IconSpinner' +import { IconStake } from '@shared/icons/IconStake' +import { IconUnstake } from '@shared/icons/IconUnstake' +import { IconWithdraw } from '@shared/icons/IconWithdraw' +import { LogoYearn } from '@shared/icons/LogoYearn' import type { TSortDirection } from '@shared/types' -import { cl, formatPercent, isZeroAddress, SUPPORTED_NETWORKS } from '@shared/utils' +import { cl, formatPercent, isZeroAddress, SUPPORTED_NETWORKS, toAddress, truncateHex } from '@shared/utils' import { formatUSD } from '@shared/utils/format' +import { copyToClipboard } from '@shared/utils/helpers' import { PLAUSIBLE_EVENTS } from '@shared/utils/plausible' +import { getNetwork } from '@shared/utils/wagmi' import type { CSSProperties, ReactElement } from 'react' -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useSearchParams } from 'react-router' +import Link from '/src/components/Link' +import type { + TGrowthDisplayMode, + TPortfolioHistoryChartTab, + TPortfolioHistoryChartTimeframe +} from './components/PortfolioHistoryChart' +import { + PortfolioHistoryChart, + PortfolioHistoryChartControls, + resolvePortfolioGrowthDisplayMode +} from './components/PortfolioHistoryChart' +import type { TPortfolioVaultGrowthChartMode } from './components/PortfolioVaultGrowthChart' +import { usePortfolioActivity } from './hooks/usePortfolioActivity' +import { usePortfolioHistory } from './hooks/usePortfolioHistory' +import { usePortfolioProtocolReturnHistory } from './hooks/usePortfolioProtocolReturnHistory' +import type { + TPortfolioActivityEntry, + TPortfolioActivityTypeFilter, + TPortfolioHistoryDenomination, + TPortfolioHistoryTimeframe +} from './types/api' const currencyFormatter = new Intl.NumberFormat('en-US', { style: 'currency', @@ -46,8 +107,10 @@ const currencyFormatter = new Intl.NumberFormat('en-US', { const headingTooltipClassName = 'rounded-lg border border-border bg-surface-secondary px-2 py-1 text-xs text-text-primary' +const metricTooltipContentClassName = 'flex max-w-[280px] flex-col gap-1 leading-relaxed' +const metricCardClassName = 'bg-surface px-5 py-3 md:px-5 md:py-2.5' const PORTFOLIO_TABS = [ - { key: 'positions', label: 'Your Vaults' }, + { key: 'positions', label: 'Account Overview' }, { key: 'activity', label: 'Activity' }, { key: 'claim-rewards', label: 'Claim Rewards' } ] as const @@ -56,33 +119,1376 @@ type TPortfolioTabKey = (typeof PORTFOLIO_TABS)[number]['key'] type TPortfolioHeaderProps = Pick< TPortfolioModel, - | 'blendedMetrics' - | 'hasKatanaHoldings' + 'blendedMetrics' | 'hasKatanaHoldings' | 'isHoldingsLoading' | 'isSearchingBalances' | 'totalPortfolioValue' +> & { + isProtocolReturnLoading: boolean + annualizedProtocolReturnPct: number | null | undefined +} + +type TPortfolioHoldingsProps = Pick< + TPortfolioModel, + | 'hasHoldings' + | 'holdingsRows' | 'isActive' | 'isHoldingsLoading' - | 'isSearchingBalances' - | 'totalPortfolioValue' + | 'openLoginModal' + | 'sortBy' + | 'sortDirection' + | 'setSortBy' + | 'setSortDirection' + | 'vaultFlags' > -type TPortfolioHoldingsProps = Pick< - TPortfolioModel, - | 'hasHoldings' - | 'holdingsRows' - | 'isActive' - | 'isHoldingsLoading' - | 'openLoginModal' - | 'sortBy' - | 'sortDirection' - | 'setSortBy' - | 'setSortDirection' - | 'vaultFlags' -> +type TPortfolioSuggestedProps = Pick + +type TPortfolioActivityProps = Pick + +type TPortfolioClaimRewardsProps = Pick + +const ACTIVITY_ACTION_LABELS: Record = { + deposit: 'Deposit', + withdraw: 'Withdraw', + stake: 'Stake', + unstake: 'Unstake', + transfer: 'Transfer', + swap: 'Swap' +} +const ACTIVITY_TYPE_FILTERS: Array<{ key: TPortfolioActivityTypeFilter; label: string }> = [ + { key: 'all', label: 'All' }, + { key: 'deposit', label: 'Deposit' }, + { key: 'withdraw', label: 'Withdraw' }, + { key: 'stake', label: 'Stake' }, + { key: 'unstake', label: 'Unstake' }, + { key: 'transfer', label: 'Transfer' }, + { key: 'swap', label: 'Swap' } +] +const ACTIVITY_CALENDAR_DAY_LABELS = [ + { key: 'sunday', label: 'S' }, + { key: 'monday', label: 'M' }, + { key: 'tuesday', label: 'T' }, + { key: 'wednesday', label: 'W' }, + { key: 'thursday', label: 'T' }, + { key: 'friday', label: 'F' }, + { key: 'saturday', label: 'S' } +] as const + +type TActivityModalFilters = { + types: TPortfolioActivityEntry['action'][] + startDate: string + endDate: string +} +type TActivityDateField = 'startDate' | 'endDate' + +function getTodayDateInputValue(): string { + const today = new Date() + const year = today.getFullYear() + const month = String(today.getMonth() + 1).padStart(2, '0') + const day = String(today.getDate()).padStart(2, '0') + + return `${year}-${month}-${day}` +} + +const DEFAULT_ACTIVITY_MODAL_FILTERS: TActivityModalFilters = { + types: [], + startDate: '', + endDate: getTodayDateInputValue() +} + +function formatActivityDisplayAmount(amountFormatted: number | null, symbol: string | null): string { + if (amountFormatted === null) { + return symbol ? `Unknown ${symbol}` : 'Unknown amount' + } + + return `${amountFormatted.toLocaleString(undefined, { maximumFractionDigits: 6 })} ${symbol ?? ''}`.trim() +} + +function formatActivityFixedValue(amountFormatted: number | null): string { + if (amountFormatted === null) { + return 'Unknown' + } + + const countableLength = (value: string): number => value.replace(/[.,]/g, '').length + const absoluteAmount = Math.abs(amountFormatted) + if (absoluteAmount === 0) { + return '0' + } + + const units = [ + { divisor: 1_000_000_000, suffix: 'B', threshold: 1_000_000_000 }, + { divisor: 1_000_000, suffix: 'M', threshold: 1_000_000 }, + { divisor: 1000, suffix: 'K', threshold: 10_000 } + ] + const unit = units.find((item) => absoluteAmount >= item.threshold) + + if (unit) { + const scaledAmount = absoluteAmount / unit.divisor + for (let decimals = 2; decimals >= 0; decimals -= 1) { + const fixedAmount = scaledAmount.toFixed(decimals) + const trimmedAmount = fixedAmount.replace(/\.?0+$/, '') + const formatted = `${countableLength(fixedAmount) <= 4 ? fixedAmount : trimmedAmount}${unit.suffix}` + if (countableLength(formatted) <= 4) { + return formatted + } + } + } + + if (absoluteAmount >= 1000) { + return new Intl.NumberFormat('en-US', { maximumFractionDigits: 0 }).format(absoluteAmount) + } + + for (let decimals = 3; decimals >= 0; decimals -= 1) { + const formatted = absoluteAmount.toFixed(decimals) + if (countableLength(formatted) <= 4) { + return formatted + } + } + + return absoluteAmount.toPrecision(1) +} + +function truncateActivityHash(hashValue: string, size: number): string { + if (size === 0 || hashValue.length <= size * 2 + 4 || !hashValue.startsWith('0x')) { + return hashValue + } + + return `0x${hashValue.slice(2, size + 2)}...${hashValue.slice(-size)}` +} + +function getActivityExplorerUrl(chainId: number, txHash: string): string | null { + const network = SUPPORTED_NETWORKS.find((item) => item.id === chainId) + const explorerBaseUrl = network?.blockExplorers?.default?.url + + return explorerBaseUrl ? `${explorerBaseUrl}/tx/${txHash}` : null +} + +function getActivityChainName(chainId: number): string { + return SUPPORTED_NETWORKS.find((item) => item.id === chainId)?.name ?? `Chain ${chainId}` +} + +function getActivityChainLogoUrl(chainId: number): string { + return `${import.meta.env.VITE_BASE_YEARN_ASSETS_URI}/chains/${chainId}/logo.svg` +} + +function formatIndexedActivityDate(timestamp: number): string { + return new Date(timestamp * 1000).toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric' + }) +} + +function formatIndexedActivityDateTime(timestamp: number): string { + return new Date(timestamp * 1000).toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: 'numeric' + }) +} + +function formatIndexedActivityTime(timestamp: number): string { + return new Date(timestamp * 1000).toLocaleTimeString('en-US', { + hour: 'numeric', + minute: 'numeric' + }) +} + +function formatActivityDateInputValue(timestamp: number): string { + const date = new Date(timestamp * 1000) + const year = date.getFullYear() + const month = String(date.getMonth() + 1).padStart(2, '0') + const day = String(date.getDate()).padStart(2, '0') + + return `${year}-${month}-${day}` +} + +function getActivityDateBoundaryTimestamp(date: string, boundary: 'start' | 'end'): number | null { + const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(date) + if (!match) { + return null + } + + const [, year, month, day] = match + const yearNumber = Number(year) + const monthIndex = Number(month) - 1 + const dayNumber = Number(day) + const dateValue = + boundary === 'start' + ? new Date(yearNumber, monthIndex, dayNumber, 0, 0, 0, 0) + : new Date(yearNumber, monthIndex, dayNumber, 23, 59, 59, 999) + + if ( + dateValue.getFullYear() !== yearNumber || + dateValue.getMonth() !== monthIndex || + dateValue.getDate() !== dayNumber + ) { + return null + } + + return Math.floor(dateValue.getTime() / 1000) +} + +function normalizeActivityModalFilters(filters: TActivityModalFilters): TActivityModalFilters { + if (filters.startDate && filters.endDate && filters.startDate > filters.endDate) { + return { + ...filters, + startDate: filters.endDate, + endDate: filters.startDate + } + } + + return filters +} + +function getActivityDateFromInputValue(date: string): Date | null { + const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(date) + if (!match) { + return null + } + + const [, year, month, day] = match + const yearNumber = Number(year) + const monthIndex = Number(month) - 1 + const dayNumber = Number(day) + const dateValue = new Date(yearNumber, monthIndex, dayNumber) + + if ( + dateValue.getFullYear() !== yearNumber || + dateValue.getMonth() !== monthIndex || + dateValue.getDate() !== dayNumber + ) { + return null + } + + return dateValue +} + +function getActivityMonthDate(date: string): Date { + const parsedDate = getActivityDateFromInputValue(date) ?? new Date() + + return new Date(parsedDate.getFullYear(), parsedDate.getMonth(), 1) +} + +function getActivityDateInputFromDate(date: Date): string { + const year = date.getFullYear() + const month = String(date.getMonth() + 1).padStart(2, '0') + const day = String(date.getDate()).padStart(2, '0') + + return `${year}-${month}-${day}` +} + +function getActivityMonthOffset(date: Date, offset: number): Date { + return new Date(date.getFullYear(), date.getMonth() + offset, 1) +} + +function getActivityMonthKey(date: Date): string { + return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}` +} + +function getEarlierActivityDate(firstDate: string, secondDate: string): string { + return firstDate < secondDate ? firstDate : secondDate +} + +function getActivityEntryTitle(entry: TPortfolioActivityEntry): string { + if (entry.displayType === 'reward_claim') { + return 'Reward Claim' + } + + if (entry.action === 'transfer' && entry.inputTokenAddress && entry.outputTokenAddress) { + return 'Zap' + } + + if (entry.action === 'transfer' && entry.transferDirection === 'in') { + return 'Transfer in' + } + + if (entry.action === 'transfer' && entry.transferDirection === 'out') { + return 'Transfer out' + } + + return ACTIVITY_ACTION_LABELS[entry.action] +} + +function getActivityEntryKey(entry: TPortfolioActivityEntry, index: number): string { + return [ + entry.chainId, + entry.txHash, + entry.vaultAddress, + entry.familyVaultAddress, + entry.action, + entry.displayType ?? 'none', + entry.transferDirection ?? 'none', + entry.assetAmount, + entry.inputTokenAddress ?? 'none', + entry.inputTokenAmount ?? 'none', + entry.outputTokenAddress ?? 'none', + entry.outputTokenAmount ?? 'none', + entry.shareAmount, + entry.timestamp, + index + ].join(':') +} + +function formatActivityMonthLabel(date: Date): string { + return date.toLocaleDateString('en-US', { month: 'long', year: 'numeric' }) +} + +function ActivityActionIcon({ + action, + displayType +}: { + action: TPortfolioActivityEntry['action'] + displayType: TPortfolioActivityEntry['displayType'] +}): ReactElement { + const iconClassName = 'size-5' + + if (displayType === 'reward_claim') { + return