From 37782f7ec1d134fb5041ec16b53800903b698db3 Mon Sep 17 00:00:00 2001 From: rossgalloway <58150151+rossgalloway@users.noreply.github.com> Date: Sun, 26 Apr 2026 22:50:01 +0000 Subject: [PATCH 01/45] Fix simple return accounting and finalize portfolio return charts (#1190) * feat: init portfolio history * fix: build * fix: getEnv * fix: env * fix: localhost connection attempt * chore: debug * chore: attempt * feat: add v2 support * fix: transfers calculation * chore: rm clearCache * feat: calculate gauge * fix: loading state * fix: optimize pps fetch * chore: rename * feat: add breakdown * feat: store aggregated data * chore: upd docs * feat: add seenTokens * feat: always recalculate today * fix: use only 12:00:00 AM timestamps * feat: add 1y option * feat: cache prices * feat: doc * feat: add batching for queries * feat: doc * feat: sequential + batching * upd: doc * fix: db spam * feat: rate limit & clear stale cache * debug: add logs * fix: query * chore: do not expose internals * chore: remove logging * chore: misc * chore: cleanup * chore: rm dead code * feat: invalidate cache * chore: address comments * chore: remove refresh for safety * chore: return clear cache for local * chore: lint * Feat/portfolio pnl 2 (#1125) * feat: init pnl * feat: consider staking * feat: omit migrate * chore: support migrate * feat: 3 calc types * feat: add totalEconomicGainUsd * chore: docs * chore: refactor * feat: actually consider price at deposit time * chore: optimize price fetch * fix: cache * chore: make fetching more aggressive * feat: test params * feat: improve totalCurrentValueUsd * feat: share events & kong * feat: fe + history fix * fix: defillama batching * feat: tooltips * feat: handle cow * feat: tune defillama * chore: tune a bit more * feat: add missing prices cache * feat: docs * fix: timeout * chore: copy * feat: stable vs volatile * Feat/portfolio pnl excessive (#1148) * feat: init excessive * chore: naming * feat: actually add drilldown * fix: v2/v3 filter * fix: version * fix: env * feat: cover crvusd rewards * feat: vault to vault migrate via enso * feat: init katana rewards * chore: upd receiver address * feat: fix bold * feat: add fetchType + paginationMode * feat: support fetchtype + pagination on vercel * feat: hash user address * feat: ensure schema is initialized * chore: lint * fix: katana pricing * feat: dummy chart * feat: breakdown * feat: breakdown for timestamp * feat: merge yvUSD * fix: nasty scroll * chore: lint * chore: hide if isHidden=true * feat: make it case insensitive * feat: init simple * feat: display * add annualized return and ETH denominated charting * Styling and add all time duration * add charts * fix staked asset withdrawal issue * fix issues with staked shares * fix simple return accounting and growth index charts * add simple return chart explainers * add growth chart numeraire spec * Finalize growth display modes * Split growth index chart modes * Keep ETH growth history when partial vaults exist * remove overall cumulative chart * improve charts and add to portfolio rows * Feat/chart tweaks (#1193) * fix: rescale growth * fix: prevent flickering * style: flickering & mobile * feat: sort overlay by index * feat: add growth position & index * fix: growth index empty values * feat: connect the dots * feat: consistent chart * Add position charts to vault pages with balance and to portfolio expanded views. other minor tweaks * fix review comments * fix review issues and dark mode tooltip * feat: dedupe transfer and stake * feat: improve aggregator (#1195) * feat: improve aggregator * chore: lint * Feat/indexed history (#1198) * feat: init activity * feat: enrich * chore: rm address --------- Co-authored-by: w84april Co-authored-by: w84april <32621609+w84april@users.noreply.github.com> --- .env.example | 5 + AGENTS.md | 7 + api/admin/invalidate-cache.ts | 90 + api/holdings/activity.test.ts | 166 + api/holdings/activity.ts | 126 + api/holdings/breakdown.test.ts | 14 + api/holdings/breakdown.ts | 154 + api/holdings/chores.ts | 31 + api/holdings/history.test.ts | 91 + api/holdings/history.ts | 156 + api/holdings/pnl.ts | 120 + api/holdings/pnl/drilldown.ts | 136 + api/holdings/pnl/simple-history.ts | 149 + api/holdings/pnl/simple.ts | 115 + api/lib/holdings/PNL.md | 657 ++++ api/lib/holdings/README.md | 702 +++++ api/lib/holdings/config.ts | 45 + api/lib/holdings/db/connection.ts | 313 ++ api/lib/holdings/index.ts | 46 + api/lib/holdings/services/activity.test.ts | 491 +++ api/lib/holdings/services/activity.ts | 614 ++++ api/lib/holdings/services/aggregator.test.ts | 605 ++++ api/lib/holdings/services/aggregator.ts | 718 +++++ api/lib/holdings/services/cache.test.ts | 37 + api/lib/holdings/services/cache.ts | 660 ++++ api/lib/holdings/services/cow.test.ts | 184 ++ api/lib/holdings/services/cow.ts | 413 +++ api/lib/holdings/services/debug.ts | 112 + api/lib/holdings/services/defillama.test.ts | 678 ++++ api/lib/holdings/services/defillama.ts | 865 +++++ api/lib/holdings/services/graphql.test.ts | 480 +++ api/lib/holdings/services/graphql.ts | 1571 ++++++++++ api/lib/holdings/services/holdings.test.ts | 49 + api/lib/holdings/services/holdings.ts | 151 + api/lib/holdings/services/kong.test.ts | 116 + api/lib/holdings/services/kong.ts | 221 ++ .../services/nestedVaultPrices.test.ts | 141 + .../holdings/services/nestedVaultPrices.ts | 172 + api/lib/holdings/services/pnl.modes.test.ts | 792 +++++ api/lib/holdings/services/pnl.test.ts | 1149 +++++++ api/lib/holdings/services/pnl.ts | 2777 +++++++++++++++++ api/lib/holdings/services/pnlShared.ts | 80 + api/lib/holdings/services/pnlSimple.test.ts | 1346 ++++++++ api/lib/holdings/services/pnlSimple.ts | 2254 +++++++++++++ api/lib/holdings/services/pnlTypes.ts | 400 +++ api/lib/holdings/services/pnlValuation.ts | 319 ++ api/lib/holdings/services/ratelimit.ts | 53 + api/lib/holdings/services/staking.ts | 97 + api/lib/holdings/services/vaults.test.ts | 181 ++ api/lib/holdings/services/vaults.ts | 564 ++++ api/lib/holdings/types.ts | 136 + api/server.ts | 1007 +++++- bun.lock | 218 +- .../2026-04-20-growth-chart-numeraire-spec.md | 369 +++ ...protocol-return-annualized-return-chart.md | 72 + ...protocol-return-cumulative-return-chart.md | 67 + docs/simple-protocol-return-growth-chart.md | 70 + ...mple-protocol-return-growth-index-chart.md | 74 + package.json | 2 + .../PortfolioHistoryBreakdownModal.tsx | 377 +++ .../components/PortfolioHistoryChart.tsx | 1084 +++++++ .../components/PortfolioVaultGrowthChart.tsx | 695 +++++ .../portfolio/hooks/usePortfolioActivity.ts | 42 + .../portfolio/hooks/usePortfolioBreakdown.ts | 32 + .../portfolio/hooks/usePortfolioHistory.ts | 66 + .../pages/portfolio/hooks/usePortfolioPnL.ts | 32 + .../hooks/usePortfolioProtocolReturn.ts | 65 + .../usePortfolioProtocolReturnHistory.ts | 69 + src/components/pages/portfolio/index.tsx | 474 ++- src/components/pages/portfolio/types/api.ts | 223 ++ .../pages/vaults/[chainID]/[address].tsx | 6 +- .../components/detail/VaultChartsSection.tsx | 115 +- .../detail/charts/ChartPrimitives.tsx | 14 +- .../detail/charts/VaultTvlGrowthChart.tsx | 344 ++ .../list/VaultsExpandedSelector.tsx | 28 +- .../vaults/components/list/VaultsListRow.tsx | 15 +- .../list/VaultsListRowExpandedContent.tsx | 26 +- .../pages/vaults/hooks/useVaultUserHistory.ts | 93 + src/components/pages/vaults/types/charts.ts | 5 + .../shared/components/MetricsCard.tsx | 5 +- src/components/shared/contexts/useWallet.tsx | 36 +- src/components/shared/hooks/useFetch.ts | 13 +- src/components/shared/utils/fetchers.ts | 4 +- vercel.json | 6 + 84 files changed, 27077 insertions(+), 220 deletions(-) create mode 100644 api/admin/invalidate-cache.ts create mode 100644 api/holdings/activity.test.ts create mode 100644 api/holdings/activity.ts create mode 100644 api/holdings/breakdown.test.ts create mode 100644 api/holdings/breakdown.ts create mode 100644 api/holdings/chores.ts create mode 100644 api/holdings/history.test.ts create mode 100644 api/holdings/history.ts create mode 100644 api/holdings/pnl.ts create mode 100644 api/holdings/pnl/drilldown.ts create mode 100644 api/holdings/pnl/simple-history.ts create mode 100644 api/holdings/pnl/simple.ts create mode 100644 api/lib/holdings/PNL.md create mode 100644 api/lib/holdings/README.md create mode 100644 api/lib/holdings/config.ts create mode 100644 api/lib/holdings/db/connection.ts create mode 100644 api/lib/holdings/index.ts create mode 100644 api/lib/holdings/services/activity.test.ts create mode 100644 api/lib/holdings/services/activity.ts create mode 100644 api/lib/holdings/services/aggregator.test.ts create mode 100644 api/lib/holdings/services/aggregator.ts create mode 100644 api/lib/holdings/services/cache.test.ts create mode 100644 api/lib/holdings/services/cache.ts create mode 100644 api/lib/holdings/services/cow.test.ts create mode 100644 api/lib/holdings/services/cow.ts create mode 100644 api/lib/holdings/services/debug.ts create mode 100644 api/lib/holdings/services/defillama.test.ts create mode 100644 api/lib/holdings/services/defillama.ts create mode 100644 api/lib/holdings/services/graphql.test.ts create mode 100644 api/lib/holdings/services/graphql.ts create mode 100644 api/lib/holdings/services/holdings.test.ts create mode 100644 api/lib/holdings/services/holdings.ts create mode 100644 api/lib/holdings/services/kong.test.ts create mode 100644 api/lib/holdings/services/kong.ts create mode 100644 api/lib/holdings/services/nestedVaultPrices.test.ts create mode 100644 api/lib/holdings/services/nestedVaultPrices.ts create mode 100644 api/lib/holdings/services/pnl.modes.test.ts create mode 100644 api/lib/holdings/services/pnl.test.ts create mode 100644 api/lib/holdings/services/pnl.ts create mode 100644 api/lib/holdings/services/pnlShared.ts create mode 100644 api/lib/holdings/services/pnlSimple.test.ts create mode 100644 api/lib/holdings/services/pnlSimple.ts create mode 100644 api/lib/holdings/services/pnlTypes.ts create mode 100644 api/lib/holdings/services/pnlValuation.ts create mode 100644 api/lib/holdings/services/ratelimit.ts create mode 100644 api/lib/holdings/services/staking.ts create mode 100644 api/lib/holdings/services/vaults.test.ts create mode 100644 api/lib/holdings/services/vaults.ts create mode 100644 api/lib/holdings/types.ts create mode 100644 docs/plans/2026-04-20-growth-chart-numeraire-spec.md create mode 100644 docs/simple-protocol-return-annualized-return-chart.md create mode 100644 docs/simple-protocol-return-cumulative-return-chart.md create mode 100644 docs/simple-protocol-return-growth-chart.md create mode 100644 docs/simple-protocol-return-growth-index-chart.md create mode 100644 src/components/pages/portfolio/components/PortfolioHistoryBreakdownModal.tsx create mode 100644 src/components/pages/portfolio/components/PortfolioHistoryChart.tsx create mode 100644 src/components/pages/portfolio/components/PortfolioVaultGrowthChart.tsx create mode 100644 src/components/pages/portfolio/hooks/usePortfolioActivity.ts create mode 100644 src/components/pages/portfolio/hooks/usePortfolioBreakdown.ts create mode 100644 src/components/pages/portfolio/hooks/usePortfolioHistory.ts create mode 100644 src/components/pages/portfolio/hooks/usePortfolioPnL.ts create mode 100644 src/components/pages/portfolio/hooks/usePortfolioProtocolReturn.ts create mode 100644 src/components/pages/portfolio/hooks/usePortfolioProtocolReturnHistory.ts create mode 100644 src/components/pages/portfolio/types/api.ts create mode 100644 src/components/pages/vaults/components/detail/charts/VaultTvlGrowthChart.tsx create mode 100644 src/components/pages/vaults/hooks/useVaultUserHistory.ts diff --git a/.env.example b/.env.example index b364548b3..25615a307 100644 --- a/.env.example +++ b/.env.example @@ -45,3 +45,8 @@ 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 +DATABASE_URL= 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/api/admin/invalidate-cache.ts b/api/admin/invalidate-cache.ts new file mode 100644 index 000000000..362b4d493 --- /dev/null +++ b/api/admin/invalidate-cache.ts @@ -0,0 +1,90 @@ +import type { VercelRequest, VercelResponse } from '@vercel/node' +import { ensureSchemaInitialized, isDatabaseEnabled } from '../lib/holdings/db/connection' +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 database is enabled + if (!isDatabaseEnabled()) { + return res.status(503).json({ error: 'Caching not enabled (DATABASE_URL 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 ensureSchemaInitialized() + + 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/holdings/activity.test.ts b/api/holdings/activity.test.ts new file mode 100644 index 000000000..cc5259204 --- /dev/null +++ b/api/holdings/activity.test.ts @@ -0,0 +1,166 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const TEST_ADDRESS = '0x2222222222222222222222222222222222222222' + +const ensureSchemaInitializedMock = vi.fn() +const checkRateLimitMock = vi.fn() +const getHoldingsActivityMock = vi.fn() + +vi.mock('../lib/holdings', () => ({ + ensureSchemaInitialized: ensureSchemaInitializedMock, + 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() + ensureSchemaInitializedMock.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', + 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) + 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', + 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: [] + }) + }) +}) diff --git a/api/holdings/activity.ts b/api/holdings/activity.ts new file mode 100644 index 000000000..e6db6e82a --- /dev/null +++ b/api/holdings/activity.ts @@ -0,0 +1,126 @@ +import type { VercelRequest, VercelResponse } from '@vercel/node' +import type { VaultVersion } from '../lib/holdings' +import { checkRateLimit, ensureSchemaInitialized } 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) +} + +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 ensureSchemaInitialized() + } catch (error) { + console.error('Holdings activity schema 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 } = 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) + ) + + 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..54b353a61 --- /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, ensureSchemaInitialized } 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 ensureSchemaInitialized() + } catch (error) { + console.error('Holdings breakdown schema 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/chores.ts b/api/holdings/chores.ts new file mode 100644 index 000000000..a37ff791a --- /dev/null +++ b/api/holdings/chores.ts @@ -0,0 +1,31 @@ +import type { VercelRequest, VercelResponse } from '@vercel/node' +import { deleteStaleCache, ensureSchemaInitialized } from '../lib/holdings' + +export default async function handler(req: VercelRequest, res: VercelResponse) { + // Verify cron secret + const authHeader = req.headers.authorization + const cronSecret = process.env.CRON_SECRET + + if (!cronSecret) { + console.error('[Chores] CRON_SECRET not configured') + return res.status(500).json({ error: 'Server misconfigured' }) + } + + if (authHeader !== `Bearer ${cronSecret}`) { + return res.status(401).json({ error: 'Unauthorized' }) + } + + try { + await ensureSchemaInitialized() + const deletedCount = await deleteStaleCache() + + return res.status(200).json({ + success: true, + deletedRows: deletedCount, + timestamp: new Date().toISOString() + }) + } catch (error) { + console.error('[Chores] Failed to run cleanup:', error) + return res.status(500).json({ error: 'Cleanup failed' }) + } +} diff --git a/api/holdings/history.test.ts b/api/holdings/history.test.ts new file mode 100644 index 000000000..0c846b767 --- /dev/null +++ b/api/holdings/history.test.ts @@ -0,0 +1,91 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const ensureSchemaInitializedMock = vi.fn() +const checkRateLimitMock = vi.fn() +const getHistoricalHoldingsChartMock = vi.fn() + +vi.mock('../lib/holdings', () => ({ + ensureSchemaInitialized: ensureSchemaInitializedMock, + 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() + ensureSchemaInitializedMock.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: '0xA7b6f3d18db39F65C8056d0892Af76c07d15Fc5a', + 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: '0xA7b6f3d18db39F65C8056d0892Af76c07d15Fc5a' + }, + headers: {} + } as any + const res = createMockResponse() + + await handler(req, res as any) + + expect(res.statusCode).toBe(200) + expect(res.body).toEqual({ + address: '0xA7b6f3d18db39F65C8056d0892Af76c07d15Fc5a', + version: 'all', + denomination: 'usd', + timeframe: '1y', + dataPoints: [ + { date: '2026-04-20', value: 0 }, + { date: '2026-04-21', value: 0 } + ] + }) + }) +}) diff --git a/api/holdings/history.ts b/api/holdings/history.ts new file mode 100644 index 000000000..2ad8fb662 --- /dev/null +++ b/api/holdings/history.ts @@ -0,0 +1,156 @@ +import type { VercelRequest, VercelResponse } from '@vercel/node' +import type { + HoldingsEventFetchType, + HoldingsEventPaginationMode, + HoldingsHistoryDenomination, + HoldingsHistoryTimeframe, + VaultVersion +} from '../lib/holdings' +import { checkRateLimit, ensureSchemaInitialized } 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() + } + + // 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 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 ensureSchemaInitialized() + } catch (error) { + console.error('Holdings history schema 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, + version: versionParam, + fetchType: fetchTypeParam, + paginationMode: paginationModeParam, + denomination: denominationParam, + timeframe: timeframeParam + } = 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 version: VaultVersion = versionParam === 'v2' || versionParam === 'v3' ? versionParam : 'all' + const fetchType = parseHoldingsEventFetchType(fetchTypeParam) + const paginationMode = parseHoldingsEventPaginationMode(paginationModeParam) + const denomination = parseHoldingsHistoryDenomination(denominationParam) + const timeframe = parseHoldingsHistoryTimeframe(timeframeParam) + + try { + const { getHistoricalHoldingsChart } = await import('../lib/holdings') + const holdings = await getHistoricalHoldingsChart( + address, + version, + fetchType, + paginationMode, + denomination, + timeframe + ) + + if (!holdings.hasActivity) { + return res.status(404).json({ error: 'No holdings found for address' }) + } + + 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) { + 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.ts b/api/holdings/pnl.ts new file mode 100644 index 000000000..30dfd4fcb --- /dev/null +++ b/api/holdings/pnl.ts @@ -0,0 +1,120 @@ +import type { VercelRequest, VercelResponse } from '@vercel/node' +import type { HoldingsEventFetchType, HoldingsEventPaginationMode } from '../lib/holdings' +import { checkRateLimit, ensureSchemaInitialized } 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 parseUnknownTransferInPnlMode(value: unknown): 'strict' | 'zero_basis' | 'windfall' { + return value === 'strict' || value === 'zero_basis' || value === 'windfall' ? value : 'windfall' +} + +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 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 ensureSchemaInitialized() + } catch (error) { + console.error('Holdings PnL schema 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 PnL API not configured', + details: 'ENVIO_GRAPHQL_URL environment variable is not set. This feature requires a running Envio indexer.' + }) + } + + const { address, version, unknownMode, 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 fetchType = parseHoldingsEventFetchType(fetchTypeParam) + const paginationMode = parseHoldingsEventPaginationMode(paginationModeParam) + + try { + const { getHoldingsPnL } = await import('../lib/holdings') + const pnl = await getHoldingsPnL( + address, + version === 'v2' || version === 'v3' ? version : 'all', + parseUnknownTransferInPnlMode(Array.isArray(unknownMode) ? unknownMode[0] : unknownMode), + fetchType, + paginationMode + ) + + if (pnl.summary.totalVaults === 0) { + return res.status(404).json({ error: 'No holdings found for address' }) + } + + res.setHeader('Cache-Control', 'public, s-maxage=300, stale-while-revalidate=600') + return res.status(200).json(pnl) + } catch (error) { + console.error('Holdings PnL 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 PnL', + message, + stack + }) + } + + return res.status(502).json({ error: 'Failed to fetch holdings PnL' }) + } +} diff --git a/api/holdings/pnl/drilldown.ts b/api/holdings/pnl/drilldown.ts new file mode 100644 index 000000000..aa3710fac --- /dev/null +++ b/api/holdings/pnl/drilldown.ts @@ -0,0 +1,136 @@ +import type { VercelRequest, VercelResponse } from '@vercel/node' +import type { HoldingsEventFetchType, HoldingsEventPaginationMode } from '../../lib/holdings' +import { checkRateLimit, ensureSchemaInitialized } 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 parseUnknownTransferInPnlMode(value: unknown): 'strict' | 'zero_basis' | 'windfall' { + return value === 'strict' || value === 'zero_basis' || value === 'windfall' ? value : 'windfall' +} + +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 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 ensureSchemaInitialized() + } catch (error) { + console.error('Holdings PnL drilldown schema 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 PnL drilldown API not configured', + details: 'ENVIO_GRAPHQL_URL environment variable is not set. This feature requires a running Envio indexer.' + }) + } + + const { + address, + vault, + version, + unknownMode, + 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' }) + } + + if (vault !== undefined && (typeof vault !== 'string' || !isValidAddress(vault))) { + return res.status(400).json({ error: 'Invalid vault address' }) + } + + const fetchType = parseHoldingsEventFetchType(fetchTypeParam) + const paginationMode = parseHoldingsEventPaginationMode(paginationModeParam) + + try { + const { getHoldingsPnLDrilldown } = await import('../../lib/holdings') + const pnl = await getHoldingsPnLDrilldown( + address, + version === 'v2' || version === 'v3' ? version : 'all', + parseUnknownTransferInPnlMode(Array.isArray(unknownMode) ? unknownMode[0] : unknownMode), + fetchType, + paginationMode, + vault + ) + + if (pnl.summary.totalVaults === 0) { + return res.status(404).json({ + error: vault ? 'No matching holdings found for address and vault' : 'No holdings found for address' + }) + } + + res.setHeader('Cache-Control', 'public, s-maxage=300, stale-while-revalidate=600') + return res.status(200).json(pnl) + } catch (error) { + console.error('Holdings PnL drilldown 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 PnL drilldown', + message, + stack + }) + } + + return res.status(502).json({ error: 'Failed to fetch holdings PnL drilldown' }) + } +} diff --git a/api/holdings/pnl/simple-history.ts b/api/holdings/pnl/simple-history.ts new file mode 100644 index 000000000..b8d36a4f7 --- /dev/null +++ b/api/holdings/pnl/simple-history.ts @@ -0,0 +1,149 @@ +import type { VercelRequest, VercelResponse } from '@vercel/node' +import type { + HoldingsEventFetchType, + HoldingsEventPaginationMode, + HoldingsHistoryTimeframe, + VaultVersion +} from '../../lib/holdings' +import { checkRateLimit, ensureSchemaInitialized } 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 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 ensureSchemaInitialized() + } catch (error) { + console.error('Holdings simple PnL history schema 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 simple PnL 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, + version: versionParam, + fetchType: fetchTypeParam, + paginationMode: paginationModeParam, + timeframe: timeframeParam + } = 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' }) + } + + if (vaultParam !== undefined && typeof vaultParam !== 'string') { + return res.status(400).json({ error: 'Invalid vault address' }) + } + + if (vaultParam && !isValidAddress(vaultParam)) { + return res.status(400).json({ error: 'Invalid vault address' }) + } + + if (vaultParam && (!chainIdParam || typeof chainIdParam !== 'string' || !Number.isInteger(Number(chainIdParam)))) { + return res.status(400).json({ error: 'Missing or invalid chainId for vault filter' }) + } + + const version: VaultVersion = versionParam === 'v2' || versionParam === 'v3' ? versionParam : 'all' + const fetchType = parseHoldingsEventFetchType(fetchTypeParam) + const paginationMode = parseHoldingsEventPaginationMode(paginationModeParam) + const timeframe = parseHoldingsHistoryTimeframe(timeframeParam) + + try { + const { getHoldingsPnLSimpleHistory } = await import('../../lib/holdings') + const history = await getHoldingsPnLSimpleHistory( + address, + version, + fetchType, + paginationMode, + timeframe, + vaultParam, + vaultParam ? Number(chainIdParam) : undefined + ) + + if (history.summary.totalVaults === 0) { + return res.status(404).json({ error: 'No holdings found for address' }) + } + + res.setHeader('Cache-Control', 'public, s-maxage=300, stale-while-revalidate=600') + return res.status(200).json(history) + } catch (error) { + console.error('Holdings simple PnL 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 simple PnL history', + message, + stack + }) + } + + return res.status(502).json({ error: 'Failed to fetch holdings simple PnL history' }) + } +} diff --git a/api/holdings/pnl/simple.ts b/api/holdings/pnl/simple.ts new file mode 100644 index 000000000..bd5f0846d --- /dev/null +++ b/api/holdings/pnl/simple.ts @@ -0,0 +1,115 @@ +import type { VercelRequest, VercelResponse } from '@vercel/node' +import type { HoldingsEventFetchType, HoldingsEventPaginationMode } from '../../lib/holdings' +import { checkRateLimit, ensureSchemaInitialized } 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 parseHoldingsEventFetchType(value: string | string[] | undefined): HoldingsEventFetchType { + return value === 'parallel' ? 'parallel' : 'seq' +} + +function parseHoldingsEventPaginationMode(value: string | string[] | undefined): HoldingsEventPaginationMode { + return value === 'all' ? 'all' : 'paged' +} + +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 ensureSchemaInitialized() + } catch (error) { + console.error('Holdings simple PnL schema 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 simple PnL API not configured', + details: 'ENVIO_GRAPHQL_URL environment variable is not set. This feature requires a running Envio indexer.' + }) + } + + const { address, version, 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 fetchType = parseHoldingsEventFetchType(fetchTypeParam) + const paginationMode = parseHoldingsEventPaginationMode(paginationModeParam) + + try { + const { getHoldingsPnLSimple } = await import('../../lib/holdings') + const pnl = await getHoldingsPnLSimple( + address, + version === 'v2' || version === 'v3' ? version : 'all', + fetchType, + paginationMode + ) + + if (pnl.summary.totalVaults === 0) { + return res.status(404).json({ error: 'No holdings found for address' }) + } + + res.setHeader('Cache-Control', 'public, s-maxage=300, stale-while-revalidate=600') + return res.status(200).json(pnl) + } catch (error) { + console.error('Holdings simple PnL 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 simple PnL', + message, + stack + }) + } + + return res.status(502).json({ error: 'Failed to fetch holdings simple PnL' }) + } +} diff --git a/api/lib/holdings/PNL.md b/api/lib/holdings/PNL.md new file mode 100644 index 000000000..7dffc94bc --- /dev/null +++ b/api/lib/holdings/PNL.md @@ -0,0 +1,657 @@ +# Holdings PnL Logic + +This document explains how `GET /api/holdings/pnl`, `GET /api/holdings/pnl/drilldown`, and `GET /api/holdings/pnl/simple` work today. + +It is written for product engineers and contributors who need to understand the accounting model without reading the full implementation in `api/lib/holdings/services/pnl.ts`. + +## Goal + +The endpoint answers a practical portfolio question: + +> For a given wallet, what vault positions exist now, and how much profit or loss can we attribute to them? + +The answer is intentionally conservative when the underlying history is ambiguous. + +## What The Endpoint Is + +The endpoint is: + +- A Yearn vault portfolio PnL calculator +- FIFO-based for known lots +- A wallet-level aggregator across vault families +- Conservative when cost basis cannot be reconstructed + +The endpoint is not: + +- A tax engine +- A generic ERC20 PnL engine +- A full wallet-wide cash-flow ledger +- A proof that every historical transfer has been classified perfectly + +## Public Surfaces + +The backend now exposes two PnL shapes: + +- `GET /api/holdings/pnl` + - compact portfolio summary plus one row per vault family + - intended for overview cards, value tables, filters, and composition views +- `GET /api/holdings/pnl/drilldown` + - the same valuation basis, but expanded with current lots, realized lot consumption, unknown-basis receipts and withdrawals, and a transaction journal + - intended for vault drawers, lot timelines, and accounting inspection UI +- `GET /api/holdings/pnl/simple` + - protocol-return summary based on withdrawable-underlying growth while the user held vault shares + - intended for performance UI that wants to ignore later asset price movement and avoid full cost-basis reconstruction + +## Core Idea + +The engine models each vault family as a set of share lots. + +A lot has: + +- A share amount +- A location + - vault + - staked +- An acquisition timestamp +- A cost basis in underlying assets, or `null` when basis is unknown + +For known-basis lots, USD basis is derived later from: + +- the lot's underlying asset basis +- the lot's acquisition timestamp +- the underlying token price at that acquisition timestamp + +Known-basis lots come from indexed deposit / withdrawal context. +Known-basis lots can also come from recognized synthetic acquisition flows, such as supported CoW settlement receipt enrichment on Ethereum mainnet. +Known-basis lots can also come from recognized zero-basis reward receipts, where the engine can identify a distributor flow that should be treated as a reward rather than as an unknown transfer-in. +Known-basis lots can also come from recognized vault-to-vault rollover flows, where source vault lots are consumed and their basis is redistributed into the destination vault shares. +Unknown-basis lots usually come from share transfers where the economic source cannot be proven. + +## Mental Model + +Think in this order: + +1. Build raw user-visible events from the indexer. +2. Enrich those events with same-transaction context. +3. Group events into vault families. +4. Build FIFO lots for each family. +5. Value remaining lots at current PPS and token price. +6. Apply an unknown-transfer policy: `strict`, `zero_basis`, or `windfall`. + +## Simple Protocol Return + +`GET /api/holdings/pnl/simple` is a separate metric, not a faster version of the full accounting route. + +It answers: + +> How much did Yearn increase the user’s withdrawable underlying amount while the user held vault shares? + +It does not answer: + +- What was the user’s accounting cost basis? +- What was the mark-to-market USD PnL after the asset price moved? +- Was every router / migrator / reward transfer classified for accounting purposes? + +The simple route uses only address-scoped events: + +- direct deposits +- direct withdrawals +- vault-token transfers in +- vault-token transfers out + +It intentionally skips transaction-hash enrichment. That means it avoids the expensive same-transaction fetches used by the full PnL route for CoW settlements, Enso rollovers, vault-to-vault basis carrying, and unknown-transfer classification. + +For each receipt, the simple route records: + +- shares received +- baseline underlying amount +- receipt timestamp +- receipt-time token price + +Deposits use indexed `assets` as the baseline. Transfer-ins use `shares * PPS at receipt`. + +For each exit, the route consumes receipt lots FIFO and compares the exit withdrawable underlying amount to the consumed baseline: + +- withdrawals use indexed `assets` +- transfer-outs use `shares * PPS at exit` + +Open lots compare current withdrawable underlying to their receipt baseline. + +The percentage is weighted across assets with receipt-time token price: + +```text +baselineWeightUsd = baselineUnderlying * receiptTokenPriceUsd +growthWeightUsd = growthUnderlying * receiptTokenPriceUsd +protocolReturnPct = growthWeightUsd / baselineWeightUsd * 100 +``` + +Because both numerator and denominator use the same receipt-time price, later token price movement does not affect `protocolReturnPct`. The metric is driven by PPS / withdrawable-underlying growth only. + +The route now also exposes an annualized variant built from time-weighted baseline exposure: + +```text +baselineExposureWeightUsdYears = + integral over time of (baselineUnderlying * receiptTokenPriceUsd) + --------------------------------------------------------------- + seconds per year + +annualizedProtocolReturnPct = + growthWeightUsd / baselineExposureWeightUsdYears * 100 +``` + +This is intended for high-turnover wallets where cumulative receipts can materially exceed the capital that was actually sitting in vaults at any one time. + +Short explainers for the chart surfaces live in: + +- [docs/simple-protocol-return-growth-chart.md](../../../docs/simple-protocol-return-growth-chart.md) +- [docs/simple-protocol-return-cumulative-return-chart.md](../../../docs/simple-protocol-return-cumulative-return-chart.md) +- [docs/simple-protocol-return-annualized-return-chart.md](../../../docs/simple-protocol-return-annualized-return-chart.md) +- [docs/simple-protocol-return-growth-index-chart.md](../../../docs/simple-protocol-return-growth-index-chart.md) + +## Vault Families + +The engine does not treat the staking wrapper as a separate investment from the underlying vault. + +A family is: + +- the underlying vault +- plus its staking wrapper, if one exists + +This matters because stake and unstake actions should usually move lots between locations, not create fresh cost basis. + +## Data Sources + +The PnL endpoint uses four main inputs. + +### 1. Envio GraphQL + +Used for: + +- underlying deposits and withdrawals +- staking deposits and withdrawals +- share transfers in and out + +### 2. Transaction-Scoped Enrichment + +Address-scoped events are often not enough. + +The PnL pipeline therefore also loads additional events from the same transaction hashes so it can understand: + +- router flows +- stake / unstake flows +- same-transaction deposit plus transfer combinations +- known migration rollovers + +Without that enrichment, many transfers would stay permanently ambiguous. + +For a small set of recognized Ethereum mainnet CoW settlement flows, the pipeline also inspects the transaction receipt and settlement logs so it can synthesize a known-basis acquisition instead of treating the received position as an unknown transfer-in. + +### 3. Kong PPS + +Historical and current price-per-share is used to translate vault shares into underlying asset amounts. + +### 4. DefiLlama Prices + +Historical and current underlying token prices are used to express PnL in USD. + +In this document, "underlying token" means the vault asset token from Kong metadata, not the Yearn share token itself. For some vaults that asset token is an LP token or pool token rather than a plain ERC20 like USDC. + +That includes: + +- acquisition-time token prices for known lot USD basis +- receipt-time token prices for unknown-lot windfall valuation +- realization-time token prices for realized USD proceeds +- current token prices for current market value + +## Processing Flow + +The implementation has two major stages. + +### Stage 1: Build Family Ledgers + +Raw events are normalized into a transaction journal. + +The ledger builder tracks: + +- vault-share lots +- staked lots +- realized entries +- recognized reward transfer-in entries +- unknown transfer-in entries +- withdrawals that consumed unknown-basis shares +- unmatched transfer-outs + +### Stage 2: Materialize API Rows + +Each family ledger is then valued into a response row with: + +- current value +- known vs unknown basis share counts +- realized PnL +- unrealized PnL +- windfall PnL when applicable +- completeness flags + +## How Known Basis Works + +### Deposits + +Underlying vault `Deposit` events create known-basis lots. + +The lot basis is the indexed `assets` amount. +The lot also keeps its acquisition timestamp. + +### Withdrawals + +Underlying vault `Withdraw` events consume existing lots using FIFO. + +For known lots: + +- proceeds come from the indexed `assets` +- consumed basis comes from the oldest remaining lots +- realized PnL is `proceeds - consumed_basis` + +In USD terms: + +- realized proceeds use the underlying token price at the withdrawal timestamp +- consumed basis uses the underlying token price at each consumed lot's acquisition timestamp + +### Unrealized PnL + +For known lots that still remain: + +- current underlying value = `shares * current PPS` +- unrealized PnL = `current underlying value - remaining cost basis` + +In USD terms: + +- current value uses the current underlying token price +- remaining basis uses the acquisition-time token price of each remaining lot +- unrealized USD PnL = `current value USD - remaining basis USD` + +This means the endpoint now captures both: + +- vault/share performance via PPS changes +- underlying token price changes between deposit and withdrawal / current time + +## How Staking Works + +Staking deposits and withdrawals are not treated as new investments by default. + +Instead, they usually mean: + +- vault shares moved into staked location +- or staked shares moved back to vault location + +The lot should stay the same. Only its location changes. + +This avoids fake realized PnL when the user simply stakes or unstakes a Yearn position. + +## How Router Flows Work + +Some transactions do not emit the economically useful event directly on the user address. + +Example: + +- user interacts with a router +- router deposits into the vault +- shares are transferred to a staking wrapper +- the user only appears on part of that flow + +The tx-hash enrichment step lets the engine inspect the whole transaction and attribute basis to the correct family. + +For recognized CoW settlement buys, the engine can synthesize a deposit-like acquisition from the receipt by combining: + +- the settlement trade log +- the asset transfer into the vault +- the share mint from the vault + +This lets the destination position start as a known-basis lot instead of a partial transfer-in when the flow is sufficiently unambiguous. + +## How Migrations Work + +The engine supports recognized migration paths. + +There are two broad cases: + +### Same-Family Rollovers + +These are transactions where shares exit and re-enter within the same family in a way that should preserve basis. + +### Known Cross-Family Migrators + +For specific migrator flows, basis can be rolled from a source family into a destination family. + +If the source side cannot be reconstructed confidently, the destination side remains partial. + +## Plain Transfers + +Plain transfers are the hardest part of the model. + +If shares arrive and the engine cannot prove where their basis came from, those shares become unknown-basis lots. + +If shares leave and the engine cannot match them to known or unknown lots cleanly, the ledger records unmatched transfer-out state. + +This is why the endpoint exposes completeness metadata instead of pretending every vault is fully known. + +## Recognized Reward Receipts + +Some transfer-ins are not treated as unknown. + +When the engine can match an incoming share transfer to a recognized reward distributor for that vault asset token, it classifies the receipt as: + +- a reward transfer-in +- a known lot with `costBasis = 0` +- a complete-basis receipt, not a partial / unknown one + +That means: + +- `costBasisStatus` stays `complete` for that receipt path +- the receipt does not contribute to `unknownCostBasisValueUsd` +- the receipt does not use `windfallPnlUsd` +- all later value is reported as normal realized / unrealized PnL on a zero-basis lot + +Today this path is intended for explicit known reward-distribution flows, not for generic airdrops or arbitrary transfers. + +## Recognized Vault Rollovers + +Some transfer-ins are not rewards and are not unknown. + +When the engine can prove that a transaction is a vault-to-vault rollover, it classifies the receipt as: + +- a migration / rollover from a source family into a destination family +- a known-basis receipt whose destination lots inherit source-lot basis +- a complete-basis receipt unless the source side already had unknown basis + +That means: + +- the destination path increments `eventCounts.migrationsIn` +- the source path increments `eventCounts.migrationsOut` +- destination shares do not become `unknownTransferInEntries` just because a router or intermediary sat in the middle +- basis is preserved across the rollover instead of being reset to zero or marked unknown + +Today this path covers explicit known migrator flows, supported Enso-mediated vault rollovers, and supported compatible nested-vault paths where one Yearn vault token is the asset of another Yearn vault. The current compatible nested-vault allowlist includes the `ysyBOLD <-> yBOLD` path on Ethereum mainnet. It is intentionally pattern-based rather than a blanket router whitelist. + +## Unknown Transfer-In Modes + +The endpoint supports three policies for unknown transfer-ins. + +Query param: + +```text +unknownMode=strict|zero_basis|windfall +``` + +Default: + +```text +windfall +``` + +## Request Controls + +The PnL endpoint also exposes event-loading controls for debugging and benchmarking: + +```text +fetchType=seq|parallel +paginationMode=paged|all +``` + +Defaults: + +```text +fetchType=seq +paginationMode=paged +``` + +### `fetchType` + +- `seq` + - address-scoped event families paginate with the normal `1000`-row page walker +- `parallel` + - address-scoped event families first try GraphQL aggregate counts, then request multiple `1000`-row pages in parallel + +This only affects the address-scoped family fetch path. Transaction-hash enrichment still happens afterwards. + +### `paginationMode` + +- `paged` + - use normal `limit/offset` pagination with `1000`-row pages +- `all` + - request each address-scoped and `transactionFrom` event family in a single query using a large hard limit instead of paging + +`all` is intended for experimentation. It bypasses the aggregate count preflight, but it uses a fixed single-query limit internally, so it should be treated as a benchmarking/debug option rather than a universally safe default. + +### `strict` + +Use this when you want the most conservative answer. + +Behavior: + +- unknown-basis shares do not contribute to realized or unrealized PnL +- their current value is reported in `unknownCostBasisValueUsd` +- `totalPnlUsd` only reflects lots with known basis + +### `zero_basis` + +Use this when you want an upper-bound estimate and are willing to assume unknown transfers were free economically. + +Behavior: + +- unknown-basis shares are treated as if basis were zero +- the full value of those shares flows into realized or unrealized PnL + +### `windfall` + +This is the default because it preserves the same economic assumption as `zero_basis`, but with better attribution. + +Behavior: + +- receipt-time fair value of unknown shares goes to `windfallPnlUsd` +- only the market move after receipt goes to `totalPnlUsd` +- `totalEconomicGainUsd = totalPnlUsd + totalWindfallPnlUsd` + +### Simple Example + +Assume: + +- unknown shares are received when worth `$1,000` +- later they are worth `$1,150` + +Results: + +```text +strict: + totalPnlUsd = 0 + unknownCostBasisValueUsd = 1,150 + +zero_basis: + totalPnlUsd = 1,150 + totalEconomicGainUsd = 1,150 + +windfall: + totalWindfallPnlUsd = 1,000 + totalPnlUsd = 150 + totalEconomicGainUsd = 1,150 +``` + +## Known-Basis USD Example + +Assume: + +- a user deposits into a WETH vault +- deposit receives shares representing `1 WETH` +- WETH is `$2,000` at deposit time +- PPS is unchanged +- later those shares still represent `1 WETH` +- WETH is now `$3,000` + +Then: + +- underlying unrealized PnL = `0 WETH` +- unrealized USD PnL = `$1,000` + +That is intentional. The endpoint now reports full USD mark-to-market PnL for known-basis lots, not only asset-denominated vault performance. + +So: + +- `zero_basis` and `windfall` can have the same economic gain +- they differ only in how that gain is classified + +## Response Fields + +### Summary + +- `totalCurrentValueUsd` + - Sum of current vault values for returned rows +- `totalUnknownCostBasisValueUsd` + - Current value of unknown-basis holdings in `strict` mode +- `totalWindfallPnlUsd` + - Receipt-time value isolated by `windfall` mode +- `totalRealizedPnlUsd` + - PnL from lots that have exited through recognized withdrawal flows +- `totalUnrealizedPnlUsd` + - PnL on lots that are still held +- `totalPnlUsd` + - `totalRealizedPnlUsd + totalUnrealizedPnlUsd` +- `totalEconomicGainUsd` + - `totalPnlUsd + totalWindfallPnlUsd` + +### Per-Vault Completeness + +- `status` + - `ok`, `missing_metadata`, `missing_price`, or `missing_pps` +- `costBasisStatus` + - `complete` or `partial` + +A vault is `partial` when the engine still has ambiguity, such as: + +- unknown transfer-in shares +- withdrawals that consumed unknown-basis shares +- unmatched transfer-outs + +### Per-Vault Current/Basis Fields + +The compact endpoint now also exposes explicit basis and underlying fields: + +- `currentUnderlying` + - current underlying asset amount represented by all current shares +- `vaultUnderlying` + - current underlying amount still in the direct vault-share location +- `stakedUnderlying` + - current underlying amount in the staking location +- `currentKnownUnderlying` + - current underlying amount attributed to known-basis lots +- `currentUnknownUnderlying` + - current underlying amount attributed to unknown-basis lots +- `knownCostBasisUnderlying` + - summed underlying asset basis from known lots +- `knownCostBasisUsd` + - USD-marked basis of known lots using acquisition-time token prices + +These fields are useful for UI without forcing a drilldown request. + +### Drilldown Fields + +The drilldown endpoint adds per-vault: + +- `currentLots.vault` +- `currentLots.staked` +- `realizedEntries` +- `rewardTransferInEntries` +- `unknownTransferInEntries` +- `unknownWithdrawalEntries` +- `journal` + +The journal is transaction-oriented. It records: + +- the computed vault-family view for that transaction +- whether the wallet had direct address activity in that transaction +- realized / stake / unstake / reward / unknown-transfer deltas +- lot summaries before and after the transaction for vault-share and staked-share locations + +For frontend drilldown: + +- `rewardTransferInEntries` should be rendered separately from `unknownTransferInEntries` +- reward entries are known zero-basis receipts, so they should not be styled as accounting ambiguity +- `txHash` currently lives on `journal`, not directly on each reward entry +- if the UI wants explorer links for reward rows, it should match a reward entry to the journal row with the same: + - `timestamp` + - `rewardInVaultShares` or `rewardInStakedShares` + +## What Is Covered Well Today + +The current implementation is strong on: + +- direct deposit / withdraw accounting +- FIFO cost basis for known lots +- stake and unstake handling for staking wrappers +- same-transaction router context, when events can be linked by tx hash +- known migration paths +- explicit treatment of unknown basis instead of silently inventing numbers + +## What Is Still Conservative Or Incomplete + +The current implementation is not fully complete in the absolute sense. + +Known limitations: + +- unrecognized migration paths still fall back to partial basis +- ambiguous transfer provenance still falls back to unknown basis +- families with no direct interaction but non-zero current shares are still surfaced as partial holdings, with current value prioritized over full historical transfer receipt pricing +- transfer-only families with zero current shares can still be omitted from the response +- missing metadata, PPS, or token prices can block full valuation for a family + +This is why the response exposes: + +- `status` +- `costBasisStatus` +- `isComplete` + +Those fields are part of the design, not an accident. + +## Practical Reading Guide + +If you are debugging one wallet: + +1. Check `summary.isComplete`. +2. Find vaults with `costBasisStatus: "partial"`. +3. Look at: + - `unknownCostBasisShares` + - `unknownCostBasisValueUsd` + - `windfallPnlUsd` + - `eventCounts` +4. Call `/api/holdings/pnl/drilldown` for the relevant vault family. +5. Use debug mode only when you also want server-side logs or transaction-specific log tables. + +## Code Map + +The main files are: + +- `api/lib/holdings/services/graphql.ts` + - fetches address-scoped and tx-scoped PnL events +- `api/lib/holdings/services/pnl.ts` + - orchestrates event loading, ledger construction, and response materialization +- `api/lib/holdings/services/pnlTypes.ts` + - shared PnL event, ledger, and response types +- `api/lib/holdings/services/pnlShared.ts` + - shared numeric and key helpers used across the PnL pipeline +- `api/lib/holdings/services/pnlValuation.ts` + - summary building, missing-metadata materialization, and unknown-mode valuation logic +- `api/lib/holdings/services/pnl.test.ts` + - core lot, staking, migration, and ledger behavior +- `api/lib/holdings/services/pnl.modes.test.ts` + - `strict`, `zero_basis`, and `windfall` behavior + +## Bottom Line + +The current PnL engine is featureful and intentionally conservative. + +It is good at not inventing certainty where the chain history is ambiguous. +It is not a perfectly complete reconstruction of every vault transfer path. + +When you read the output: + +- `strict` answers “what can we prove?” +- `zero_basis` answers “what if unknown transfers were free?” +- `windfall` answers “what is the same economic gain, but split into receipt-time value and later market PnL?” diff --git a/api/lib/holdings/README.md b/api/lib/holdings/README.md new file mode 100644 index 000000000..e633ba321 --- /dev/null +++ b/api/lib/holdings/README.md @@ -0,0 +1,702 @@ +# Holdings APIs + +Calculates historical USD values and portfolio PnL for a user's Yearn vault positions. + +## Architecture + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ Frontend │ +│ usePortfolioHistory() hook │ +│ │ │ +│ ▼ │ +│ GET /api/holdings/history|breakdown?address=0x... │ +└─────────────────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────────────┐ +│ Bun API Server (port 3001) │ +│ │ │ +│ ▼ │ +│ ┌─────────────────┐ │ +│ │ Aggregator │ ◄── Main orchestrator │ +│ └────────┬────────┘ │ +│ │ │ +│ ┌───────────────────┼───────────────────┐ │ +│ ▼ ▼ ▼ │ +│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ +│ │ Cache │ │ Services │ │ Holdings │ │ +│ │ (Postgres) │ │ │ │ Calculator │ │ +│ └─────────────┘ └──────┬──────┘ └─────────────┘ │ +│ │ │ +│ ┌─────────────┼─────────────┐ │ +│ ▼ ▼ ▼ │ +│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ +│ │ Envio │ │ Kong │ │ DefiLlama│ │ +│ │ GraphQL │ │ API │ │ API │ │ +│ └──────────┘ └──────────┘ └──────────┘ │ +└─────────────────────────────────────────────────────────────────────────────┘ +``` + +## Data Flow + +### 1. Request Handling +``` +User Request → API Server → Aggregator → Response +``` + +### 2. Data Collection + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ For 365-day period: │ +│ │ +│ 1. Check Cache ──────────────────────────────────────────────► │ +│ │ │ +│ └─► If cached: Use cached daily totals │ +│ └─► If not cached: │ +│ │ +│ 2. Fetch Events from Envio ─────────────────────────────────► │ +│ • V3 Deposits (owner = user) │ +│ • V3 Withdrawals (owner = user) │ +│ • V2 Deposits (recipient = user) │ +│ • V2 Withdrawals (recipient = user) │ +│ • Transfers In (receiver = user) │ +│ • Transfers Out (sender = user) │ +│ │ +│ 3. Build Position Timeline ─────────────────────────────────► │ +│ • Calculate share balance at each point in time │ +│ │ +│ 4. For each vault position: │ +│ a. Fetch vault metadata (Kong) ─────────────────────────► │ +│ • Vault asset token address, decimals │ +│ │ +│ b. Fetch Price Per Share (Kong) ────────────────────────► │ +│ • Historical PPS timeseries │ +│ │ +│ c. Fetch Token Price (DefiLlama) ───────────────────────► │ +│ • Historical USD prices for the vault asset token │ +│ │ +│ 5. Calculate USD Value per day ────────────────────────────► │ +│ │ +│ 6. Save daily totals to Cache ─────────────────────────────► │ +└─────────────────────────────────────────────────────────────────┘ +``` + +## USD Value Calculation + +``` +USD Value = Shares × Price Per Share × Underlying Token Price + +Where: + • Shares = User's vault token balance (from events) + • PPS = Price Per Share at that timestamp (from Kong) + • Token Price = USD price of the vault asset token (from DefiLlama) +``` + +### Example: +``` +User has: 100 yvUSDC shares +PPS: 1.05 (vault has earned 5% yield) +USDC: $1.00 + +USD Value = 100 × 1.05 × 1.00 = $105.00 +``` + +For LP-based vaults, the "token price" can be an LP token price rather than a plain asset like USDC. For example, a Curve strategy vault may use the Curve LP token as its asset token, and the USD value is still computed as: + +`vault shares × PPS × LP token USD price` + +## Components + +### Services + +| Service | Source | Purpose | +|---------|--------|---------| +| `graphql.ts` | Envio Indexer | Fetch deposit/withdraw/transfer events with pagination | +| `kong.ts` | Kong API | Fetch historical Price Per Share timeseries | +| `vaults.ts` | Kong API | Fetch vault metadata (token info, decimals, staking) | +| `defillama.ts` | DefiLlama API | Fetch historical token prices | +| `cow.ts` | Ethereum RPC + CoW settlement logs | Synthesize known-basis acquisitions for recognized CoW settlement flows | +| `cache.ts` | PostgreSQL | Cache daily USD totals per user + positive token prices + exact price misses | +| `holdings.ts` | Local | Build position timeline, calculate share balances | +| `aggregator.ts` | Local | Orchestrate all services, main entry point | +| `pnl.ts` | Local | Build family ledgers, FIFO lots, and PnL summaries | +| `pnlSimple.ts` | Local | Build address-scoped protocol-return exposure ledgers without cost-basis reconstruction | + +### Data Types + +```typescript +// API Response (internal) +interface HoldingsHistoryResponse { + address: string + periodDays: number + dataPoints: Array<{ + date: string // "2024-01-15" + timestamp: number // Unix timestamp (end of the settled UTC day) + totalUsdValue: number + }> +} + +// Simplified response (from server) +{ + address: string + version: 'v2' | 'v3' | 'all' + dataPoints: Array<{ + date: string + value: number + }> +} +``` + +## Vault Versions + +The API supports both V2 and V3 Yearn vaults: + +| Version | Deposit Event | Withdraw Event | User Field | +|---------|---------------|----------------|------------| +| V3 | `Deposit` | `Withdraw` | `owner` | +| V2 | `V2Deposit` | `V2Withdraw` | `recipient` | + +Events are normalized internally to a common format for timeline processing. + +## Transfer Events + +Transfer events track vault shares moving between addresses (not through deposit/withdraw): + +- **Transfers In**: Shares received from another address +- **Transfers Out**: Shares sent to another address + +### Special Cases + +1. **Mint filtering**: For vaults with indexed deposit events, transfers from `0x000...` (mints) are excluded (already tracked via Deposit events) +2. **Burn filtering**: For vaults with indexed withdraw events, transfers to `0x000...` (burns) are excluded (already tracked via Withdraw events) +3. **Transfer-only vaults**: Some vaults (e.g., staking contracts) don't have indexed Deposit events. For these, mint events ARE included to properly track deposits + +### Staking Vaults + +Staking vault positions are tracked via the `stakingToVaultMap` in `vaults.ts`. When a user deposits into a staking contract, the system maps the staking address to its underlying vault metadata for proper valuation. + +### PnL-Specific Event Enrichment + +The PnL endpoint fetches more context than the history endpoint: + +1. Address-scoped events for the user +2. Additional transaction-scoped events for the same transaction hashes + +That transaction-hash enrichment lets the PnL engine match same-transaction router flows, staking stake/unstake moves, and known migration rollovers even when the economically relevant event was not emitted directly on the user's address filter. + +For some recognized Ethereum mainnet CoW settlement transactions, the PnL path also inspects the transaction receipt and settlement logs to synthesize a deposit-like acquisition with known basis. This reduces false partial-cost-basis cases for routed buys into vault asset/share positions. + +## API Endpoints + +### GET `/api/holdings/history` +Holdings history for charts (date + total value). + +The history series ends at the latest settled UTC day, not an intraday "today" point. This keeps the daily series cacheable and avoids recomputing a moving final point on every request. For current portfolio value, use `/api/holdings/pnl`. +Vaults with `isHidden=true` in authoritative Kong metadata are excluded from history totals and do not contribute to chart points. + +```bash +curl "http://localhost:3001/api/holdings/history?address=0x..." +curl "http://localhost:3001/api/holdings/history?address=0x...&fetchType=parallel" +curl "http://localhost:3001/api/holdings/history?address=0x...&paginationMode=all" +``` + +Query params: +- `address` (required): Ethereum address +- `version` (optional): `v2`, `v3`, or `all` (default: `all`) +- `refresh` (optional, local Bun server only): `true` or `1` to force cache refresh +- `fetchType` (optional): `seq` or `parallel` (default: `seq`) +- `paginationMode` (optional): `paged` or `all` (default: `paged`) + +Response: +```json +{ + "address": "0x...", + "version": "all", + "dataPoints": [ + { "date": "2024-01-01", "value": 1000.50 }, + { "date": "2024-01-02", "value": 1005.25 } + ] +} +``` + +### GET `/api/holdings/breakdown` +Per-vault valuation breakdown for the latest settled holdings-history point. + +This endpoint uses the same settled UTC day as the final point on `/api/holdings/history`, so it is useful for explaining why the latest chart value is what it is. It does not use current intraday balances or current intraday prices. +Vaults with `isHidden=true` in authoritative Kong metadata are excluded from the returned rows. + +```bash +curl "http://localhost:3001/api/holdings/breakdown?address=0x..." +curl "http://localhost:3001/api/holdings/breakdown?address=0x...&version=v3" +curl "http://localhost:3001/api/holdings/breakdown?address=0x...&fetchType=parallel&paginationMode=all" +``` + +Query params: +- `address` (required): Ethereum address +- `version` (optional): `v2`, `v3`, or `all` (default: `all`) +- `fetchType` (optional): `seq` or `parallel` (default: `seq`) +- `paginationMode` (optional): `paged` or `all` (default: `paged`) + +Response (abridged): +```json +{ + "address": "0x...", + "version": "all", + "date": "2026-04-07", + "timestamp": 1775529600, + "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/pnl` +FIFO-based realized and unrealized PnL for the user’s vault activity. + +For the dedicated accounting-model walkthrough, see [`PNL.md`](./PNL.md). +Vaults with `isHidden=true` in authoritative Kong metadata are excluded from both returned rows and summary totals. + +```bash +curl "http://localhost:3001/api/holdings/pnl?address=0x..." +curl "http://localhost:3001/api/holdings/pnl?address=0x...&unknownMode=windfall" +curl "http://localhost:3001/api/holdings/pnl?address=0x...&fetchType=parallel" +curl "http://localhost:3001/api/holdings/pnl?address=0x...&paginationMode=all" +``` + +Query params: +- `address` (required): Ethereum address +- `version` (optional): `v2`, `v3`, or `all` (default: `all`) +- `unknownMode` (optional): `strict`, `zero_basis`, or `windfall` (default: `windfall`) +- `fetchType` (optional): `seq` or `parallel` (default: `seq`) +- `paginationMode` (optional): `paged` or `all` (default: `paged`) + +Response (abridged): +```json +{ + "address": "0x...", + "version": "all", + "unknownTransferInPnlMode": "windfall", + "generatedAt": "2026-03-16T12:00:00.000Z", + "summary": { + "totalVaults": 5, + "completeVaults": 4, + "partialVaults": 1, + "totalCurrentValueUsd": 1500.0, + "totalUnknownCostBasisValueUsd": 0, + "totalWindfallPnlUsd": 100.0, + "totalRealizedPnlUsd": 20.5, + "totalUnrealizedPnlUsd": 45.25, + "totalPnlUsd": 65.75, + "totalEconomicGainUsd": 165.75, + "byCategory": { + "stable": { + "totalPnlUsd": 40.0, + "totalEconomicGainUsd": 60.0 + }, + "volatile": { + "totalPnlUsd": 25.75, + "totalEconomicGainUsd": 105.75 + } + }, + "isComplete": false + }, + "vaults": [ + { + "chainId": 1, + "vaultAddress": "0x...", + "status": "ok", + "costBasisStatus": "partial", + "unknownTransferInPnlMode": "windfall", + "currentUnderlying": 105.0, + "knownCostBasisUnderlying": 100.0, + "knownCostBasisUsd": 100.0, + "unknownCostBasisValueUsd": 0, + "windfallPnlUsd": 100.0, + "realizedPnlUsd": 12.5, + "unrealizedPnlUsd": 4.25, + "totalPnlUsd": 16.75, + "totalEconomicGainUsd": 116.75, + "currentValueUsd": 105.0, + "metadata": { + "symbol": "USDC", + "decimals": 6, + "assetDecimals": 6, + "tokenAddress": "0x...", + "category": "stable" + } + } + ] +} +``` + +The live response also includes: + +- share balances +- vault-share vs staked-share splits +- per-vault event counts +- explicit underlying amounts (`currentUnderlying`, `vaultUnderlying`, `stakedUnderlying`) +- explicit known-basis metrics (`knownCostBasisUnderlying`, `knownCostBasisUsd`, `currentKnownUnderlying`, `currentUnknownUnderlying`) + +Notes: +- Deposits create FIFO lots using the indexed `assets` and `shares` values. +- Known-basis USD PnL is true mark-to-market PnL: deposit lots keep their acquisition timestamp, and USD basis is valued using the underlying token price at deposit time. +- Withdrawals realize PnL from the oldest remaining lots first. +- `totalPnlUsd = totalRealizedPnlUsd + totalUnrealizedPnlUsd`. +- `totalEconomicGainUsd = totalPnlUsd + totalWindfallPnlUsd`. +- Some recognized reward-distribution flows are treated as explicit zero-basis reward receipts rather than unknown transfers. Those shares are considered economically free, but they stay `costBasisStatus: "complete"` and contribute to normal realized / unrealized PnL instead of `windfallPnlUsd`. +- `summary.byCategory.stable` and `summary.byCategory.volatile` split `totalPnlUsd` and `totalEconomicGainUsd` by Kong vault category while the top-level summary remains the portfolio-wide total. +- Staking wrappers are collapsed into the underlying vault family. Staked shares and directly held vault shares share the same FIFO lots and only change location. +- Underlying vault `Deposit` and `Withdraw` events define cost basis and realized proceeds. Staking `Deposit` and `Withdraw` events are treated as stake or unstake moves, not as economic entries. +- Same-transaction router flows can carry basis into or out of a staking vault family when the transfer can be matched to an indexed underlying vault deposit or withdrawal in the same transaction. +- Recognized vault-to-vault rollover transactions can roll basis from a source family into a destination family, including known migrator flows, supported Enso-mediated rollovers on Ethereum mainnet, and supported compatible nested-vault paths where one Yearn vault token is the asset of another Yearn vault. The current compatible nested-vault allowlist includes the `ysyBOLD <-> yBOLD` path on Ethereum mainnet. When source basis cannot be reconstructed, the destination shares stay partial / unknown-basis. +- Plain share transfers may leave some lots with unknown cost basis. Those vaults are returned with `costBasisStatus: "partial"`. In `strict` mode, the unmatched current portion is reported in `unknownCostBasisValueUsd`; in `zero_basis` and `windfall`, that value is zeroed and the economics are attributed according to `unknownTransferInPnlMode`. +- The endpoint keeps families with non-zero current shares even if they only arrived through transfers, but transfer-only families with zero remaining shares may still be omitted. Those transfer-only holdings are marked partial and prioritized for current-value completeness over full historical price reconstruction. +- `isComplete` becomes `false` when at least one returned vault still has partial / unknown basis. + +### GET `/api/holdings/pnl/simple` +Protocol-return summary for the user’s vault exposure. + +This route is intentionally not a cost-basis PnL engine. It measures how much Yearn increased the user’s withdrawable underlying amount while the user held vault shares. Receipt-time token prices are used only to weight different assets into one portfolio percentage. + +It uses only address-scoped events, so it avoids transaction-hash enrichment, CoW receipt enrichment, and FIFO cost-basis classification. This makes it cheaper than `/api/holdings/pnl`, but it should be labeled as protocol return rather than accounting PnL. +Like `/api/holdings/pnl`, it excludes vaults with `isHidden=true` in authoritative Kong metadata. + +```bash +curl "http://localhost:3001/api/holdings/pnl/simple?address=0x..." +curl "http://localhost:3001/api/holdings/pnl/simple?address=0x...&version=v3" +curl "http://localhost:3001/api/holdings/pnl/simple?address=0x...&fetchType=parallel" +``` + +Query params: +- `address` (required): Ethereum address +- `version` (optional): `v2`, `v3`, or `all` (default: `all`) +- `fetchType` (optional): `seq` or `parallel` (default: `seq`) +- `paginationMode` (optional): `paged` or `all` (default: `paged`) + +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 both numerator and denominator use the same receipt-time token price, later asset price movement does not affect `protocolReturnPct`. + +Response (abridged): + +```json +{ + "address": "0x...", + "version": "all", + "summary": { + "totalVaults": 5, + "completeVaults": 5, + "partialVaults": 0, + "baselineWeightUsd": 7000, + "growthWeightUsd": 700, + "protocolReturnPct": 10, + "isComplete": true + }, + "vaults": [ + { + "chainId": 1, + "vaultAddress": "0x...", + "status": "ok", + "baselineUnderlying": 1000, + "growthUnderlying": 100, + "baselineWeightUsd": 1000, + "growthWeightUsd": 100, + "protocolReturnPct": 10, + "receiptCount": 1, + "exitCount": 0 + } + ] +} +``` + +### GET `/api/holdings/pnl/drilldown` +Lot-level drilldown for the PnL engine. This is the "excessive" companion to `/api/holdings/pnl`. + +Use it when the frontend needs current lots, realized lot consumption, unknown-basis receipts / withdrawals, or a transaction journal showing how the family lot state changed over time. +Like `/api/holdings/pnl`, it excludes vaults with `isHidden=true` in authoritative Kong metadata. + +```bash +curl "http://localhost:3001/api/holdings/pnl/drilldown?address=0x..." +curl "http://localhost:3001/api/holdings/pnl/drilldown?address=0x...&vault=0x..." +curl "http://localhost:3001/api/holdings/pnl/drilldown?address=0x...&unknownMode=windfall" +``` + +Query params: +- `address` (required): Ethereum address +- `vault` (optional): family vault or staking vault address to limit the response to one family +- `version` (optional): `v2`, `v3`, or `all` (default: `all`) +- `unknownMode` (optional): `strict`, `zero_basis`, or `windfall` (default: `windfall`) +- `fetchType` (optional): `seq` or `parallel` (default: `seq`) +- `paginationMode` (optional): `paged` or `all` (default: `paged`) + +Response additions per vault: +- `currentLots.vault` and `currentLots.staked` +- `realizedEntries` with consumed lots and USD/underlying proceeds/basis/PnL +- `rewardTransferInEntries` for recognized zero-basis reward receipts +- `unknownTransferInEntries` with receipt-time valuation +- `unknownWithdrawalEntries` with proceeds and consumed unknown lots +- `journal` rows with before/after lot summaries for vault-share and staked-share locations + +Notes: +- `rewardTransferInEntries` are known-basis receipts with `costBasis = 0`, so they are not affected by `unknownMode`. +- The drilldown journal includes `rewardInVaultShares` / `rewardInStakedShares` and the transaction hash. If the UI wants explorer links for reward rows, it should join the reward entry to the matching journal row. + +The drilldown response uses the same summary fields and top-level identity fields as `/api/holdings/pnl`, but each vault row is expanded for UI drilldown rather than table rendering. + +### Unknown Transfer-In Modes + +Unknown share transfers can be interpreted three ways: + +- `strict` + - Unknown shares do not contribute to PnL. + - Their current value is reported in `unknownCostBasisValueUsd`. +- `zero_basis` + - Unknown shares are treated as if they were acquired for zero cost. + - Receipt-time value and any later market move both end up inside realized / unrealized PnL. +- `windfall` (default) + - Unknown shares are still treated as free economically, but the gain is split into two parts. + - Receipt-time fair value is isolated in `windfallPnlUsd`. + - `totalPnlUsd` only tracks market movement after receipt. + - `totalEconomicGainUsd = totalPnlUsd + totalWindfallPnlUsd`. + +`zero_basis` and `windfall` can report the same `totalEconomicGainUsd`. The difference is attribution: + +- `zero_basis` books the full gain as market PnL. +- `windfall` books receipt-time value as windfall and only the post-receipt move as market PnL. + +Example: + +```text +Unknown shares received at $1,000 value and later worth $1,150 + +strict: + totalPnlUsd = 0 + unknownCostBasisValueUsd = 1,150 + +zero_basis: + totalPnlUsd = 1,150 + totalEconomicGainUsd = 1,150 + +windfall: + totalWindfallPnlUsd = 1,000 + totalPnlUsd = 150 + totalEconomicGainUsd = 1,150 +``` + +## Supported Chains + +| Chain | ID | DefiLlama Prefix | +|-------|-----|-----------------| +| Ethereum | 1 | ethereum | +| Base | 8453 | base | +| Arbitrum | 42161 | arbitrum | +| Polygon | 137 | polygon | + +## Environment Variables + +| Variable | Required | Default | Description | +|----------|----------|---------|-------------| +| `ENVIO_GRAPHQL_URL` | No | `http://localhost:8080/v1/graphql` | Envio indexer GraphQL endpoint | +| `ENVIO_PASSWORD` | No | `''` (empty) | Envio Hasura admin secret (header skipped if empty or 'testing') | +| `DATABASE_URL_PREVIEW` | No | `null` | Preview PostgreSQL connection string. Used in preference to `DATABASE_URL` when set. | +| `DATABASE_URL` | No | `null` | Default PostgreSQL connection string (caching disabled if neither DB env var is set) | +| `ETHEREUM_RPC_URL` | No | `https://ethereum-rpc.publicnode.com` | Mainnet RPC used for receipt-level enrichment such as CoW settlement decoding | +| `DEFILLAMA_API_KEY` | No | `''` (empty) | Enables the paid DefiLlama GET route at `https://pro-api.llama.fi/{key}/coins/batchHistorical?coins=...` | +| `ADMIN_SECRET` | No | `null` | Secret for admin endpoints (cache invalidation). Use 32+ char random string. | + +**Note**: Kong and DefiLlama base URLs are hardcoded: +- Kong: `https://kong.yearn.fi` +- DefiLlama free: `https://coins.llama.fi` +- DefiLlama pro: `https://pro-api.llama.fi` + +## Pagination & Performance + +### The 1000 Result Limit + +Envio's hosted service enforces a **hard limit of 1000 results per query**. This is server-side and cannot be increased by requesting a larger `limit` parameter. Users with extensive transaction history require pagination to fetch all events. + +### Current Solution: Sequential Pagination + +Each event type is fetched with sequential pagination - pages are fetched one after another until fewer than `BATCH_SIZE` (1000) results are returned: + +``` +Query: User has 3,500 transfers + │ + ├─► Fetch offset=0, limit=1000 → 1000 results (continue) + ├─► Fetch offset=1000, limit=1000 → 1000 results (continue) + ├─► Fetch offset=2000, limit=1000 → 1000 results (continue) + └─► Fetch offset=3000, limit=1000 → 500 results (done!) +``` + +All 6 event types (V3 deposits/withdrawals, V2 deposits/withdrawals, transfers in/out) are fetched in parallel, but each type paginates sequentially within itself. + +### PnL Fetch Controls + +The PnL endpoint exposes request-time controls for benchmarking alternate address-scoped fetch strategies: + +- `fetchType=seq|parallel` + - `seq`: normal `1000`-row sequential pagination + - `parallel`: try GraphQL aggregate counts first, then fetch address-scoped pages concurrently + +- `paginationMode=paged|all` + - `paged`: normal `limit/offset` pagination + - `all`: issue one large query per event family instead of paging + +`parallel` depends on these aggregate roots being available on the GraphQL schema: +- `Deposit_aggregate`, `Withdraw_aggregate` +- `V2Deposit_aggregate`, `V2Withdraw_aggregate` +- `Transfer_aggregate` + +If aggregate roots are unavailable in a given environment, the code falls back to sequential pagination. `paginationMode=all` bypasses the aggregate preflight entirely and is meant for experimentation rather than as a production default. + +### maxTimestamp Optimization + +When only calculating a few missing days, the `maxTimestamp` parameter limits event fetching: + +```typescript +// Only fetch events up to end of last missing day +const maxTimestamp = Math.max(...missingTimestamps) + 86400 +const events = await fetchUserEvents(userAddress, 'all', maxTimestamp, fetchType, paginationMode) +``` + +## Caching Strategy + +The cache stores **daily USD totals per user** (not per-vault breakdowns). + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Cache Lookup Flow │ +│ │ +│ Request for 365 days of data │ +│ │ │ +│ ▼ │ +│ ┌─────────────────┐ │ +│ │ Check DB cache │ │ +│ │ for date range │ │ +│ └────────┬────────┘ │ +│ │ │ +│ ▼ │ +│ ┌──────────────────────────────────────────────┐ │ +│ │ If all settled UTC days are cached │ │ +│ │ → return directly from cache │ │ +│ └──────────────────────────────────────────────┘ │ +│ ┌──────────────────────────────────────────────┐ │ +│ │ If some settled days are missing │ │ +│ │ → fetch only up to the last missing day │ │ +│ │ → calculate missing days │ │ +│ │ → save new daily totals to cache │ │ +│ └──────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────┘ +``` + +The history series ends at the latest settled UTC day rather than an intraday moving "today" point. Current portfolio value should come from `/api/holdings/pnl`. + +### Cache Layers + +1. **PostgreSQL** (server): + - Daily totals cached per user and vault version + - Token prices cached globally (shared across all users) + - Exact token/timestamp price misses cached globally with TTL to suppress repeated unsupported DefiLlama fetches +2. **HTTP Cache-Control** (CDN): `s-maxage=300, stale-while-revalidate=600` +3. **TanStack Query** (client): 4-hour cache duration + +## Database Schema + +```sql +-- User daily totals (one row per user per day) +CREATE TABLE IF NOT EXISTS holdings_totals ( + user_address_hash VARCHAR(64) NOT NULL, + version VARCHAR(8) NOT NULL DEFAULT 'all', + date DATE NOT NULL, + usd_value NUMERIC NOT NULL, + updated_at TIMESTAMP DEFAULT NOW(), + PRIMARY KEY (user_address_hash, version, date) +); + +-- Token price cache (shared across all users) +CREATE TABLE IF NOT EXISTS token_prices ( + token_key VARCHAR(100) NOT NULL, -- e.g., "ethereum:0xa0b8..." + timestamp INTEGER NOT NULL, -- Unix timestamp (end of the settled UTC day) + price NUMERIC NOT NULL, + created_at TIMESTAMP DEFAULT NOW(), + PRIMARY KEY (token_key, timestamp) +); + +-- Exact token/timestamp misses for unsupported DefiLlama points +CREATE TABLE IF NOT EXISTS token_price_misses ( + token_key VARCHAR(100) NOT NULL, + timestamp INTEGER NOT NULL, + expires_at TIMESTAMP NOT NULL, + created_at TIMESTAMP DEFAULT NOW(), + PRIMARY KEY (token_key, timestamp) +); + +-- Vault invalidation timestamps (for cache invalidation) +CREATE TABLE IF NOT EXISTS vault_invalidations ( + vault_address VARCHAR(42) NOT NULL, + chain_id INTEGER NOT NULL, + invalidated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + PRIMARY KEY (vault_address, chain_id) +); + +CREATE INDEX IF NOT EXISTS idx_token_prices_token_key ON token_prices(token_key); +CREATE INDEX IF NOT EXISTS idx_token_price_misses_token_key ON token_price_misses(token_key); +CREATE INDEX IF NOT EXISTS idx_token_price_misses_expires_at ON token_price_misses(expires_at); +CREATE INDEX IF NOT EXISTS idx_vault_invalidations_time ON vault_invalidations(invalidated_at); +``` + +- `holdings_totals`: ~365 rows per hashed user key for full history +- `token_prices`: Shared positive-price cache, reduces DefiLlama API calls for exact timestamp hits +- `token_price_misses`: Shared negative cache for exact unsupported price points, currently stored with a 7-day TTL +- `vault_invalidations`: Tracks when vaults were invalidated for lazy cache refresh + +## Cache Invalidation + +When new vaults are added to the indexer, cached data becomes stale. The system uses **lazy per-vault invalidation**: + +1. After deploying indexer with new vault, call `POST /api/admin/invalidate-cache` with the vault addresses +2. This records invalidation timestamps in `vault_invalidations` table +3. On user requests, the system checks if any of the user's vaults were invalidated after their cache was written +4. If stale, the user's cache is cleared and recalculated + +This approach: +- Only refreshes cache for users who hold the newly-indexed vault +- Refresh happens lazily on next request (after client cache expires) +- No need to know affected users upfront diff --git a/api/lib/holdings/config.ts b/api/lib/holdings/config.ts new file mode 100644 index 000000000..bf1921ac4 --- /dev/null +++ b/api/lib/holdings/config.ts @@ -0,0 +1,45 @@ +export interface HoldingsConfig { + readonly envioGraphqlUrl: string + readonly envioPassword: string + readonly databaseUrl: string | null + readonly ethereumRpcUrl: string + readonly kongBaseUrl: string + readonly defillamaBaseUrl: string + readonly defillamaProBaseUrl: string + readonly defillamaApiKey: string + readonly historyDays: number +} + +export const config: HoldingsConfig = { + get envioGraphqlUrl() { + return process.env.ENVIO_GRAPHQL_URL ?? 'http://localhost:8080/v1/graphql' + }, + get envioPassword() { + return process.env.ENVIO_PASSWORD ?? '' + }, + get databaseUrl() { + return process.env.DATABASE_URL_PREVIEW ?? process.env.DATABASE_URL ?? null + }, + get ethereumRpcUrl() { + return process.env.ETHEREUM_RPC_URL ?? 'https://ethereum-rpc.publicnode.com' + }, + kongBaseUrl: 'https://kong.yearn.fi', + defillamaBaseUrl: 'https://coins.llama.fi', + defillamaProBaseUrl: 'https://pro-api.llama.fi', + get defillamaApiKey() { + return process.env.DEFILLAMA_API_KEY?.trim() ?? '' + }, + historyDays: 365 +} + +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.DATABASE_URL_PREVIEW && !process.env.DATABASE_URL) { + console.warn('[Holdings] DATABASE_URL_PREVIEW / DATABASE_URL not set, caching disabled') + } + if (!process.env.ETHEREUM_RPC_URL) { + console.warn('[Holdings] ETHEREUM_RPC_URL not set, using default public mainnet RPC') + } +} diff --git a/api/lib/holdings/db/connection.ts b/api/lib/holdings/db/connection.ts new file mode 100644 index 000000000..a4a1ead98 --- /dev/null +++ b/api/lib/holdings/db/connection.ts @@ -0,0 +1,313 @@ +import { createHash } from 'node:crypto' +import { Pool } from '@neondatabase/serverless' +import { config } from '../config' + +interface QueryResult { + rows: T[] + rowCount: number +} + +interface DatabasePool { + query: >(text: string, params?: unknown[]) => Promise> + end: () => Promise +} + +type DatabaseQueryError = Error & { + code?: string + status?: number +} + +let pool: DatabasePool | null = null +let schemaInitializationPromise: Promise | null = null +let databaseDisabled = false + +const DB_QUERY_TIMEOUT_MS = 5_000 +const RETRYABLE_CONNECTION_ERROR_CODES = new Set([ + 'ECONNRESET', + 'ECONNREFUSED', + 'ConnectionRefused', + 'ETIMEDOUT', + 'EAI_AGAIN', + 'UND_ERR_SOCKET', + 'UND_ERR_CONNECT_TIMEOUT', + 'UND_ERR_HEADERS_TIMEOUT', + 'UND_ERR_ABORTED', + '57P01', + '57P02', + '57P03', + '53300' +]) + +function normalizeUserAddress(userAddress: string): string { + return userAddress.toLowerCase() +} + +function toUserAddressHash(userAddress: string): string { + return createHash('sha256').update(normalizeUserAddress(userAddress)).digest('hex') +} + +function withTimeout(promise: Promise, timeoutMs: number, label: string): Promise { + return new Promise((resolve, reject) => { + const timeoutId = setTimeout(() => { + reject(new Error(`${label} timed out after ${timeoutMs}ms`)) + }, timeoutMs) + + promise.then( + (value) => { + clearTimeout(timeoutId) + resolve(value) + }, + (error) => { + clearTimeout(timeoutId) + reject(error) + } + ) + }) +} + +function disableDatabase(reason: string, error?: unknown): void { + databaseDisabled = true + console.error(`[Holdings DB] Disabling database-backed cache: ${reason}`, error ?? '') + + const currentPool = pool + pool = null + + if (currentPool) { + Promise.resolve(currentPool.end()).catch((closeError) => { + console.error('[Holdings DB] Failed to close pool while disabling database cache:', closeError) + }) + } +} + +export function shouldDisableDatabaseOnQueryError(error: unknown): boolean { + const databaseError = error as Partial + const code = typeof databaseError?.code === 'string' ? databaseError.code : null + const status = typeof databaseError?.status === 'number' ? databaseError.status : null + const message = + typeof databaseError?.message === 'string' + ? databaseError.message.toLowerCase() + : error instanceof Error + ? error.message.toLowerCase() + : String(error).toLowerCase() + + if (code !== null && (RETRYABLE_CONNECTION_ERROR_CODES.has(code) || code.startsWith('08'))) { + return true + } + + if (status !== null && status >= 500) { + return true + } + + return message.includes('timed out') || message.includes('timeout') || message.includes('failed to fetch') +} + +async function createPool(): Promise { + if (!config.databaseUrl || databaseDisabled) { + return null + } + + try { + Pool.poolQueryViaFetch = true + const neonPool = new Pool({ connectionString: config.databaseUrl }) + + return { + query: async (text: string, params?: unknown[]) => { + try { + const result = await withTimeout(neonPool.query(text, params), DB_QUERY_TIMEOUT_MS, 'Holdings DB query') + return { rows: result.rows as T[], rowCount: result.rowCount ?? 0 } + } catch (error) { + if (shouldDisableDatabaseOnQueryError(error)) { + disableDatabase('query failure', error) + } + throw error + } + }, + end: () => neonPool.end() + } + } catch (error) { + console.error('[Holdings DB] Failed to create pool:', error) + return null + } +} + +export async function getPool(): Promise { + if (databaseDisabled) { + return null + } + + if (pool === null && config.databaseUrl) { + pool = await createPool() + } + return pool +} + +export async function initializeSchema(): Promise { + const db = await getPool() + if (!db) { + console.log('[Holdings DB] No database configured, skipping schema initialization') + return + } + + const schema = ` + CREATE TABLE IF NOT EXISTS holdings_totals ( + user_address_hash VARCHAR(64) NOT NULL, + version VARCHAR(8) NOT NULL DEFAULT 'all', + date DATE NOT NULL, + usd_value NUMERIC NOT NULL, + updated_at TIMESTAMP DEFAULT NOW(), + PRIMARY KEY (user_address_hash, version, date) + ); + + ALTER TABLE holdings_totals ADD COLUMN IF NOT EXISTS user_address_hash VARCHAR(64); + ALTER TABLE holdings_totals ADD COLUMN IF NOT EXISTS version VARCHAR(8); + UPDATE holdings_totals SET version = 'all' WHERE version IS NULL; + ALTER TABLE holdings_totals ALTER COLUMN version SET DEFAULT 'all'; + ALTER TABLE holdings_totals ALTER COLUMN version SET NOT NULL; + + CREATE TABLE IF NOT EXISTS token_prices ( + token_key VARCHAR(100) NOT NULL, + timestamp INTEGER NOT NULL, + price NUMERIC NOT NULL, + created_at TIMESTAMP DEFAULT NOW(), + PRIMARY KEY (token_key, timestamp) + ); + + CREATE TABLE IF NOT EXISTS token_price_misses ( + token_key VARCHAR(100) NOT NULL, + timestamp INTEGER NOT NULL, + expires_at TIMESTAMP NOT NULL, + created_at TIMESTAMP DEFAULT NOW(), + PRIMARY KEY (token_key, timestamp) + ); + + CREATE TABLE IF NOT EXISTS rate_limits ( + ip VARCHAR(45) PRIMARY KEY, + request_count INTEGER DEFAULT 1, + window_start TIMESTAMP DEFAULT NOW() + ); + + CREATE TABLE IF NOT EXISTS vault_invalidations ( + vault_address VARCHAR(42) NOT NULL, + chain_id INTEGER NOT NULL, + invalidated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + PRIMARY KEY (vault_address, chain_id) + ); + + CREATE INDEX IF NOT EXISTS idx_token_prices_token_key ON token_prices(token_key); + CREATE INDEX IF NOT EXISTS idx_token_price_misses_token_key ON token_price_misses(token_key); + CREATE INDEX IF NOT EXISTS idx_token_price_misses_expires_at ON token_price_misses(expires_at); + CREATE INDEX IF NOT EXISTS idx_rate_limits_window ON rate_limits(window_start); + CREATE INDEX IF NOT EXISTS idx_vault_invalidations_time ON vault_invalidations(invalidated_at); + ` + + try { + await db.query(schema) + await migrateHoldingsTotalsAddressStorage(db) + console.log('[Holdings DB] Schema initialized successfully') + } catch (error) { + if (shouldDisableDatabaseOnQueryError(error)) { + disableDatabase('schema initialization failed', error) + return + } + + console.warn( + '[Holdings DB] Schema initialization skipped; continuing with existing database access:', + error instanceof Error ? error.message : error + ) + } +} + +export function ensureSchemaInitialized(): Promise { + if (!isDatabaseEnabled()) { + return Promise.resolve() + } + + if (!schemaInitializationPromise) { + schemaInitializationPromise = initializeSchema().catch((error) => { + schemaInitializationPromise = null + throw error + }) + } + + return schemaInitializationPromise +} + +async function migrateHoldingsTotalsAddressStorage(db: DatabasePool): Promise { + const columnsResult = await db.query<{ column_name: string }>( + `SELECT column_name + FROM information_schema.columns + WHERE table_name = 'holdings_totals'` + ) + const columns = new Set(columnsResult.rows.map((row) => row.column_name)) + const hasLegacyUserAddressColumn = columns.has('user_address') + const hasUserAddressHashColumn = columns.has('user_address_hash') + + if (!hasUserAddressHashColumn) { + return + } + + if (hasLegacyUserAddressColumn) { + const legacyAddressesResult = await db.query<{ user_address: string }>( + `SELECT DISTINCT user_address + FROM holdings_totals + WHERE user_address IS NOT NULL + AND (user_address_hash IS NULL OR user_address_hash = '')` + ) + + for (const row of legacyAddressesResult.rows) { + const normalizedAddress = normalizeUserAddress(row.user_address) + const userAddressHash = toUserAddressHash(normalizedAddress) + await db.query( + `UPDATE holdings_totals + SET user_address_hash = $1 + WHERE user_address = $2 + AND (user_address_hash IS NULL OR user_address_hash = '')`, + [userAddressHash, normalizedAddress] + ) + } + } + + const unresolvedHashesResult = await db.query<{ count: string }>( + `SELECT COUNT(*)::text AS count + FROM holdings_totals + WHERE user_address_hash IS NULL OR user_address_hash = ''` + ) + const unresolvedHashes = Number(unresolvedHashesResult.rows[0]?.count ?? '0') + + if (unresolvedHashes > 0) { + throw new Error(`Unable to migrate holdings_totals user address hashes for ${unresolvedHashes} rows`) + } + + const primaryKeyResult = await db.query<{ column_name: string }>( + `SELECT kcu.column_name + FROM information_schema.table_constraints tc + JOIN information_schema.key_column_usage kcu + ON tc.constraint_name = kcu.constraint_name + AND tc.table_schema = kcu.table_schema + WHERE tc.table_name = 'holdings_totals' + AND tc.constraint_type = 'PRIMARY KEY' + ORDER BY kcu.ordinal_position` + ) + const primaryKeyColumns = primaryKeyResult.rows.map((row) => row.column_name) + const expectedPrimaryKeyColumns = ['user_address_hash', 'version', 'date'] + const hasExpectedPrimaryKey = + primaryKeyColumns.length === expectedPrimaryKeyColumns.length && + primaryKeyColumns.every((columnName, index) => columnName === expectedPrimaryKeyColumns[index]) + + if (!hasExpectedPrimaryKey) { + await db.query('ALTER TABLE holdings_totals DROP CONSTRAINT IF EXISTS holdings_totals_pkey') + await db.query( + 'ALTER TABLE holdings_totals ADD CONSTRAINT holdings_totals_pkey PRIMARY KEY (user_address_hash, version, date)' + ) + } + + await db.query('ALTER TABLE holdings_totals ALTER COLUMN user_address_hash SET NOT NULL') + + if (hasLegacyUserAddressColumn) { + await db.query('ALTER TABLE holdings_totals DROP COLUMN IF EXISTS user_address') + } +} + +export function isDatabaseEnabled(): boolean { + return config.databaseUrl !== null && !databaseDisabled +} diff --git a/api/lib/holdings/index.ts b/api/lib/holdings/index.ts new file mode 100644 index 000000000..ffb776a72 --- /dev/null +++ b/api/lib/holdings/index.ts @@ -0,0 +1,46 @@ +export { config, validateConfig } from './config' +export { ensureSchemaInitialized, initializeSchema, isDatabaseEnabled } from './db/connection' +export { + getHoldingsActivity, + type HoldingsActivityAction, + type HoldingsActivityEntry, + type HoldingsActivityResponse +} from './services/activity' +export { + getHistoricalHoldings, + getHistoricalHoldingsChart, + getHoldingsBreakdown, + type HoldingsBreakdownResponse, + type HoldingsBreakdownVaultResponse, + type HoldingsHistoryChartResponse, + type HoldingsHistoryDenomination, + type HoldingsHistoryResponse, + type HoldingsHistoryTimeframe +} from './services/aggregator' +export { clearUserCache, deleteStaleCache } from './services/cache' +export { + fetchRecentAddressScopedActivityEvents, + fetchUserEvents, + type HoldingsEventFetchType, + type HoldingsEventPaginationMode, + type VaultVersion +} from './services/graphql' +export { + getHoldingsPnL, + getHoldingsPnLDrilldown, + type HoldingsPnLDrilldownResponse, + type HoldingsPnLDrilldownVault, + type HoldingsPnLResponse, + type HoldingsPnLVault, + type UnknownTransferInPnlMode +} from './services/pnl' +export { + getHoldingsPnLSimple, + getHoldingsPnLSimpleHistory, + type HoldingsPnLSimpleHistoryPoint, + type HoldingsPnLSimpleHistoryResponse, + type HoldingsPnLSimpleResponse, + type HoldingsPnLSimpleVault, + type THoldingsPnLSimpleStatus +} from './services/pnlSimple' +export { checkRateLimit } from './services/ratelimit' diff --git a/api/lib/holdings/services/activity.test.ts b/api/lib/holdings/services/activity.test.ts new file mode 100644 index 000000000..c852ef8e9 --- /dev/null +++ b/api/lib/holdings/services/activity.test.ts @@ -0,0 +1,491 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const fetchRecentAddressScopedActivityEventsMock = vi.fn() +const fetchActivityEventsByTransactionHashesMock = vi.fn() +const fetchMultipleVaultsMetadataMock = vi.fn() + +vi.mock('./graphql', () => ({ + fetchRecentAddressScopedActivityEvents: fetchRecentAddressScopedActivityEventsMock, + fetchActivityEventsByTransactionHashes: fetchActivityEventsByTransactionHashesMock +})) + +vi.mock('./vaults', () => ({ + fetchMultipleVaultsMetadata: fetchMultipleVaultsMetadataMock +})) + +const UNDERLYING_VAULT = '0xbe53a109b494e5c9f97b9cd39fe969be68bf6204' +const STAKING_VAULT = '0x622fa41799406b120f9a40da843d358b7b2cfee3' +const HIDDEN_VAULT = '0x0000000000000000000000000000000000000123' +const UNKNOWN_VAULT = '0x0000000000000000000000000000000000000456' +const USER_ADDRESS = '0x2222222222222222222222222222222222222222' +const INTERMEDIARY = '0x4Fe93ebC4Ce6Ae4f81601cC7Ce7139023919E003' + +function createDepositEvent(args: { + id: string + vaultAddress: string + transactionHash: string + blockTimestamp: number + logIndex: number + assets: string + shares?: string +}) { + return { + id: args.id, + vaultAddress: args.vaultAddress, + chainId: 1, + blockNumber: args.blockTimestamp, + blockTimestamp: args.blockTimestamp, + logIndex: args.logIndex, + transactionHash: args.transactionHash, + transactionFrom: USER_ADDRESS, + owner: USER_ADDRESS, + 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 +}) { + return { + id: args.id, + vaultAddress: args.vaultAddress, + 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 +}) { + return { + id: args.id, + vaultAddress: args.vaultAddress, + 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 + } +} + +describe('getHoldingsActivity', () => { + beforeEach(() => { + vi.resetModules() + vi.clearAllMocks() + fetchActivityEventsByTransactionHashesMock.mockResolvedValue({ + deposits: [], + withdrawals: [], + transfers: [] + }) + }) + + 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', + vaultAddress: UNDERLYING_VAULT, + familyVaultAddress: UNDERLYING_VAULT, + assetSymbol: 'USDC', + assetAmount: '2000000', + assetAmountFormatted: 2, + shareAmount: '2000000000000000000', + shareAmountFormatted: 2, + status: 'ok' + }, + { + chainId: 1, + txHash: '0xbbb', + timestamp: 190, + action: 'stake', + vaultAddress: STAKING_VAULT, + familyVaultAddress: UNDERLYING_VAULT, + assetSymbol: 'yvUSDC', + assetAmount: '2000000000000000000', + assetAmountFormatted: 2, + shareAmount: '2000000000000000000', + shareAmountFormatted: 2, + status: 'ok' + }, + { + chainId: 1, + txHash: '0xccc', + timestamp: 180, + action: 'unstake', + vaultAddress: STAKING_VAULT, + familyVaultAddress: UNDERLYING_VAULT, + assetSymbol: 'yvUSDC', + assetAmount: '1000000000000000000', + assetAmountFormatted: 1, + shareAmount: '1000000000000000000', + shareAmountFormatted: 1, + status: 'ok' + } + ]) + expect(response.pageInfo).toEqual({ + hasMore: false, + nextOffset: null + }) + }) + + 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', + vaultAddress: UNKNOWN_VAULT, + familyVaultAddress: UNKNOWN_VAULT, + assetSymbol: null, + assetAmount: '123456789', + assetAmountFormatted: 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', + vaultAddress: UNDERLYING_VAULT, + familyVaultAddress: UNDERLYING_VAULT, + assetSymbol: 'USDC', + assetAmount: '2000000', + assetAmountFormatted: 2, + shareAmount: '2000000000000000000', + shareAmountFormatted: 2, + status: 'ok' + } + ]) + expect(response.pageInfo).toEqual({ + hasMore: true, + nextOffset: 2 + }) + }) + + it('recovers routed withdrawals from address transfers plus tx-scoped withdraw events', 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 + } + ] + ]) + ) + + 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', + vaultAddress: UNDERLYING_VAULT, + familyVaultAddress: UNDERLYING_VAULT, + assetSymbol: 'USDC', + assetAmount: '1072609', + assetAmountFormatted: 1.072609, + 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..1217ba94a --- /dev/null +++ b/api/lib/holdings/services/activity.ts @@ -0,0 +1,614 @@ +import type { DepositEvent, TransferEvent, WithdrawEvent } from '../types' +import type { TransactionActivityEvents, VaultVersion } from './graphql' +import { fetchActivityEventsByTransactionHashes, fetchRecentAddressScopedActivityEvents } from './graphql' +import { formatAmount, lowerCaseAddress, minBigInt, toVaultKey, ZERO } from './pnlShared' +import { getFamilyVaultAddress, isStakingVault } from './staking' +import { fetchMultipleVaultsMetadata } from './vaults' + +export type HoldingsActivityAction = 'deposit' | 'withdraw' | 'stake' | 'unstake' + +export interface HoldingsActivityEntry { + chainId: number + txHash: string + timestamp: number + action: HoldingsActivityAction + vaultAddress: string + familyVaultAddress: string + assetSymbol: string | null + assetAmount: string + assetAmountFormatted: number | null + shareAmount: string + shareAmountFormatted: number | null + status: 'ok' | 'missing_metadata' +} + +export interface HoldingsActivityResponse { + address: string + version: VaultVersion + limit: number + offset: 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 + 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 + assets: bigint + shares: bigint +} + +type TRecentActivityWindow = { + candidateEvents: TActivityEvent[] + hasPotentialMore: boolean +} + +type TDepositWithdrawalSums = { + assets: bigint + shares: bigint + latestEvent: Extract | null +} + +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), + 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 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 +}): 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, + assets: args.assetAmount, + shares: args.shareAmount + } +} + +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 + ) + + return [ + 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 + }) + : 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 + }) + : 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) + }) + : 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) + }) + : null + })() + : null + ].filter((event): event is TResolvedActivityEvent => event !== null) +} + +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) +} + +async function loadRecentActivityWindowAttempt( + userAddress: string, + version: VaultVersion, + targetTransactionCount: number, + limitPerSource: number, + attempt: number +): Promise { + const recentEvents = await fetchRecentAddressScopedActivityEvents(userAddress, version, limitPerSource) + 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 + ) +} + +async function loadRecentActivityWindow( + userAddress: string, + version: VaultVersion, + targetTransactionCount: number +): Promise { + return loadRecentActivityWindowAttempt( + userAddress, + version, + targetTransactionCount, + Math.max(targetTransactionCount * 4, 20), + 0 + ) +} + +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')) + ] +} + +export async function getHoldingsActivity( + userAddress: string, + version: VaultVersion = 'all', + limit = 10, + offset = 0 +): Promise { + const boundedLimit = Math.max(1, limit) + const boundedOffset = Math.max(0, offset) + 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 selectedTransactionKeySet = new Set(pageTransactionKeys) + const selectedAddressEvents = candidateEvents.filter((event) => selectedTransactionKeySet.has(toTxKey(event))) + const transactionEvents = + pageTransactionKeys.length > 0 + ? await fetchActivityEventsByTransactionHashes(buildTransactionHashesByChain(pageTransactionKeys), version) + : { + deposits: [], + withdrawals: [], + transfers: [] + } + const selectedEvents = mergeActivityEvents([ + ...selectedAddressEvents, + ...normalizeTransactionActivityEvents(transactionEvents) + ]) + const classifiedEvents = classifyActivityEvents(selectedEvents, userAddress) + const vaultIdentifiers = classifiedEvents.reduce>( + (identifiers, event) => { + const alreadyIncluded = identifiers.some( + (identifier) => identifier.chainId === event.chainId && identifier.vaultAddress === event.vaultAddress + ) + + if (!alreadyIncluded) { + identifiers.push({ chainId: event.chainId, vaultAddress: event.vaultAddress }) + } + + return identifiers + }, + [] + ) + const metadata = vaultIdentifiers.length > 0 ? await fetchMultipleVaultsMetadata(vaultIdentifiers) : new Map() + const entries = classifiedEvents.flatMap((event) => { + const eventMetadata = metadata.get(toVaultKey(event.chainId, event.vaultAddress)) ?? null + + if (eventMetadata?.isHidden) { + return [] + } + + return [ + { + chainId: event.chainId, + txHash: event.txHash, + timestamp: event.timestamp, + action: event.action, + vaultAddress: event.vaultAddress, + familyVaultAddress: event.familyVaultAddress, + assetSymbol: eventMetadata?.token.symbol ?? null, + assetAmount: event.assets.toString(), + assetAmountFormatted: eventMetadata ? formatAmount(event.assets, eventMetadata.token.decimals) : null, + shareAmount: event.shares.toString(), + shareAmountFormatted: eventMetadata ? formatAmount(event.shares, eventMetadata.decimals) : null, + status: eventMetadata ? 'ok' : 'missing_metadata' + } + ] + }) + const hasMore = + selectedTransactionKeys.length > boundedOffset + boundedLimit || + (pageTransactionKeys.length === boundedLimit && + selectedTransactionKeys.length < targetTransactionCount && + hasPotentialMore) + + return { + address: lowerCaseAddress(userAddress), + version, + limit: boundedLimit, + offset: boundedOffset, + pageInfo: { + hasMore, + nextOffset: hasMore ? boundedOffset + boundedLimit : null + }, + entries + } +} diff --git a/api/lib/holdings/services/aggregator.test.ts b/api/lib/holdings/services/aggregator.test.ts new file mode 100644 index 000000000..8165ca016 --- /dev/null +++ b/api/lib/holdings/services/aggregator.test.ts @@ -0,0 +1,605 @@ +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 getChainPrefixMock = vi.fn() +const getPriceAtTimestampMock = vi.fn() + +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, + getChainPrefix: getChainPrefixMock, + getPriceAtTimestamp: getPriceAtTimestampMock +})) + +describe('getHistoricalHoldings', () => { + beforeEach(() => { + toSettledDayTimestampMock.mockImplementation((timestamp: number) => timestamp + 1) + checkCacheStalenessMock.mockResolvedValue(false) + clearUserCacheMock.mockResolvedValue(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', 86501, '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', 86501, 'seq', 'paged') + }) + + 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', 86601, '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] } + ]) + 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 first wallet event 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(50, 200) + expect(getCachedTotalsWithTimestampMock).not.toHaveBeenCalled() + 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('marks history as active even when only unsettled same-day events exist', async () => { + const userAddress = '0x93a62da5a14c80f265dabc077fcee437b1a0efde' + const sameDayTimeline = [{ blockTimestamp: 250, blockNumber: 2 }] + + generateDailyTimestampsMock.mockReturnValue([100, 200]) + timestampToDateStringMock.mockImplementation((timestamp: number) => `date-${timestamp}`) + getCachedTotalsWithTimestampMock.mockResolvedValue({ totals: [], oldestUpdatedAt: null }) + fetchUserEventsMock.mockResolvedValue({ + deposits: [], + withdrawals: [], + transfersIn: [], + transfersOut: [] + }) + buildPositionTimelineMock.mockReturnValue(sameDayTimeline) + getUniqueVaultsMock.mockReturnValue([]) + generateDailyTimestampsFromRangeMock.mockReturnValue([]) + + const { getHistoricalHoldingsChart } = await import('./aggregator') + const response = await getHistoricalHoldingsChart(userAddress, 'all', 'seq', 'paged', 'usd', '1y') + + expect(fetchUserEventsMock).toHaveBeenCalledWith(userAddress, 'all', 86601, '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([[201, 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] }]) + 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([[101, 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] }]) + 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..1431b2027 --- /dev/null +++ b/api/lib/holdings/services/aggregator.ts @@ -0,0 +1,718 @@ +import { config } from '../config' +import type { VaultMetadata } from '../types' +import type { CachedTotal } from './cache' +import { checkCacheStaleness, clearUserCache, getCachedTotalsWithTimestamp, saveCachedTotals } from './cache' +import { debugLog } from './debug' +import { + fetchHistoricalPrices, + fetchHistoricalPricesForTokenTimestamps, + getChainPrefix, + 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, + getAssetVaultMetadataLookupIdentifiers, + getNestedVaultPpsIdentifiersFromPriceRequests, + mergeVaultIdentifiers +} from './nestedVaultPrices' +import { toVaultKey } from './pnlShared' +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 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' + +async function resolveNestedVaultAssetMetadata( + vaultMetadata: Map +): Promise> { + const assetVaultIdentifiers = getAssetVaultMetadataLookupIdentifiers(vaultMetadata) + + if (assetVaultIdentifiers.length === 0) { + return vaultMetadata + } + + const assetVaultMetadata = await fetchMultipleVaultsMetadata(assetVaultIdentifiers, { skipSnapshotFallback: true }) + return new Map([...vaultMetadata, ...assetVaultMetadata]) +} + +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 +} + +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 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' +): Promise { + const defaultDays = config.historyDays + const dayTimestamps = generateDailyTimestamps(defaultDays, 1) + const latestSettledDayTimestamp = dayTimestamps[dayTimestamps.length - 1] + let timestamps = timeframe === 'all' ? [] : dayTimestamps + let periodDays = defaultDays + 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 + if (timeframe !== 'all') { + const startDate = timestampToDateString(timestamps[0]) + const endDate = timestampToDateString(timestamps[timestamps.length - 1]) + const cachedResult = await getCachedTotalsWithTimestamp(userAddress, version, startDate, endDate) + cachedTotals = cachedResult.totals + oldestUpdatedAt = cachedResult.oldestUpdatedAt + } + debugLog('history', 'loaded cached totals for request', { + version, + timeframe, + cachedTotals: cachedTotals.length, + oldestUpdatedAt: oldestUpdatedAt?.toISOString() ?? null + }) + + let cachedByDate = new Map(cachedTotals.map((total) => [total.date, total.usdValue])) + + // Always fetch the full event set, then filter vaults by authoritative Kong metadata version. + const maxTimestamp = toSettledDayTimestamp(latestSettledDayTimestamp + 86400) + const events = await fetchUserEvents(userAddress, 'all', maxTimestamp, fetchType, paginationMode) + const timeline = buildPositionTimeline(events.deposits, events.withdrawals, events.transfersIn, events.transfersOut) + const hasActivity = timeline.length > 0 + debugLog('history', 'built position timeline', { + fetchType, + paginationMode, + deposits: events.deposits.length, + withdrawals: events.withdrawals.length, + transfersIn: events.transfersIn.length, + transfersOut: events.transfersOut.length, + timelineEntries: timeline.length + }) + + if (timeframe === 'all' && timeline.length > 0) { + const firstEventTimestamp = timeline[0]?.blockTimestamp ?? timestamps[0] + const allTimestamps = generateDailyTimestampsFromRange(firstEventTimestamp, latestSettledDayTimestamp) + + if (allTimestamps.length > 0) { + timestamps = allTimestamps + periodDays = allTimestamps.length + } + } + + const rawVaults = timeline.length > 0 ? getUniqueVaults(timeline) : [] + const baseVaultMetadata = rawVaults.length > 0 ? await fetchMultipleVaultsMetadata(rawVaults) : new Map() + const vaultMetadata = await resolveNestedVaultAssetMetadata(baseVaultMetadata) + const vaults = filterVaultsByAuthoritativeVersion(rawVaults, vaultMetadata, version) + debugLog('history', 'resolved authoritative vault versions for history', { + version, + fetchType, + paginationMode, + rawVaults: rawVaults.length, + filteredVaults: vaults.length, + metadataResolved: vaultMetadata.size + }) + + // Check if any vaults have been invalidated since cache was written + if (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, version) + 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 + }) + + 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 + }) + + const newTotals: CachedTotal[] = [] + + 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 seenTokens = new Set() + const underlyingTokens: Array<{ chainId: number; address: string }> = [] + for (const vault of vaults) { + 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 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 [ppsData, fetchedPriceData] = await Promise.all([ + fetchMultipleVaultsPPS(ppsIdentifiers), + fetchHistoricalPricesForTokenTimestamps(priceRequests) + ]) + const priceData = deriveNestedVaultAssetPriceData({ + priceData: fetchedPriceData, + priceRequests, + vaultMetadata, + ppsData + }) + debugLog('history', 'resolved metadata and PPS for history', { + version, + fetchType, + paginationMode, + vaults: ppsIdentifiers.length, + metadataResolved: vaultMetadata.size, + ppsResolved: ppsData.size, + emptyPpsTimelines: Array.from(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 + }) + + 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 = 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 + }) + } + + if (newTotals.length > 0) { + await saveCachedTotals(userAddress, version, newTotals) + debugLog('history', 'saved recalculated totals to cache', { + version, + fetchType, + paginationMode, + newTotals: newTotals.length + }) + } + } + + // 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 + }) + + 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' +): Promise { + const holdings = await getHistoricalHoldings(userAddress, version, fetchType, paginationMode, timeframe) + + 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(config.historyDays, 1) + const breakdownDayTimestamp = targetTimestamp ?? timestamps[timestamps.length - 1] + const breakdownTimestamp = toSettledDayTimestamp(breakdownDayTimestamp) + const breakdownDate = timestampToDateString(breakdownTimestamp) + debugLog('breakdown', 'starting holdings breakdown', { + version, + fetchType, + paginationMode, + timestamp: breakdownTimestamp, + date: breakdownDate + }) + + 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 seenTokens = new Set() + const underlyingTokens: Array<{ chainId: number; address: string }> = [] + for (const vault of vaults) { + 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: [breakdownTimestamp] + })) + const priceRequests = expandNestedVaultAssetPriceRequests(basePriceRequests, vaultMetadata) + const ppsIdentifiers = mergeVaultIdentifiers([ + ...vaults, + ...getNestedVaultPpsIdentifiersFromPriceRequests(basePriceRequests, vaultMetadata) + ]) + const [ppsData, fetchedPriceData] = await Promise.all([ + fetchMultipleVaultsPPS(ppsIdentifiers), + fetchHistoricalPricesForTokenTimestamps(priceRequests) + ]) + 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 + }) + + const results: HoldingsBreakdownVaultResponse[] = [] + + for (const vault of vaults) { + const vaultKey = toVaultKey(vault.chainId, vault.vaultAddress) + const metadata = vaultMetadata.get(vaultKey) + const shares = getShareBalanceAtTimestamp(timeline, vault.vaultAddress, vault.chainId, breakdownTimestamp) + const ppsMap = ppsData.get(vaultKey) + const pps = ppsMap ? getPPS(ppsMap, breakdownTimestamp) : null + const decimals = metadata?.decimals ?? 18 + const sharesFormatted = Number(shares) / 10 ** decimals + + 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, breakdownTimestamp) : 0 + usdValue = pps ? 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: shares.toString(), + 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..c19b61d54 --- /dev/null +++ b/api/lib/holdings/services/cache.test.ts @@ -0,0 +1,37 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const getPoolMock = vi.fn() +const isDatabaseEnabledMock = vi.fn() + +vi.mock('../db/connection', () => ({ + getPool: getPoolMock, + isDatabaseEnabled: isDatabaseEnabledMock +})) + +describe('deleteStaleCache', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('prunes stale rate limit rows during cache cleanup', async () => { + const queryMock = vi + .fn() + .mockResolvedValueOnce({ rowCount: 2 }) + .mockResolvedValueOnce({ rowCount: 3 }) + .mockResolvedValueOnce({ rowCount: 4 }) + + isDatabaseEnabledMock.mockReturnValue(true) + getPoolMock.mockResolvedValue({ + query: queryMock, + end: vi.fn() + }) + + const { deleteStaleCache } = await import('./cache') + const deletedCount = await deleteStaleCache() + + expect(queryMock).toHaveBeenNthCalledWith(1, expect.stringContaining('DELETE FROM holdings_totals')) + expect(queryMock).toHaveBeenNthCalledWith(2, expect.stringContaining('DELETE FROM token_price_misses')) + expect(queryMock).toHaveBeenNthCalledWith(3, expect.stringContaining('DELETE FROM rate_limits')) + expect(deletedCount).toBe(9) + }) +}) diff --git a/api/lib/holdings/services/cache.ts b/api/lib/holdings/services/cache.ts new file mode 100644 index 000000000..7d0388697 --- /dev/null +++ b/api/lib/holdings/services/cache.ts @@ -0,0 +1,660 @@ +import { createHash } from 'node:crypto' +import { getPool, isDatabaseEnabled } from '../db/connection' +import { debugError, debugLog } from './debug' +import type { VaultVersion } from './graphql' + +export interface CachedTotal { + date: string + usdValue: number +} + +export interface CachedPrice { + tokenKey: string + timestamp: number + price: number +} + +export interface CachedPriceMiss { + tokenKey: string + timestamp: number +} + +export interface CachedPriceLookup { + tokenKey: string + timestamps: number[] +} + +const PRICE_MISS_TTL_MS = 7 * 24 * 60 * 60 * 1_000 + +function normalizeUserAddress(userAddress: string): string { + return userAddress.toLowerCase() +} + +function getUserAddressCacheKey(userAddress: string): string { + return createHash('sha256').update(normalizeUserAddress(userAddress)).digest('hex') +} + +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 normalizeCachedPriceLookups(lookups: CachedPriceLookup[]): CachedPriceLookup[] { + const timestampsByToken = lookups.reduce>>((result, lookup) => { + if (lookup.timestamps.length === 0) { + return result + } + + const tokenKey = lookup.tokenKey.toLowerCase() + const timestampSet = result.get(tokenKey) ?? new Set() + lookup.timestamps.forEach((timestamp) => { + timestampSet.add(timestamp) + }) + result.set(tokenKey, timestampSet) + return result + }, new Map()) + + return Array.from(timestampsByToken.entries()).map(([tokenKey, timestampSet]) => ({ + tokenKey, + timestamps: Array.from(timestampSet).sort((a, b) => a - b) + })) +} + +function countCachedPriceLookupPoints(lookups: CachedPriceLookup[]): number { + return lookups.reduce((total, lookup) => total + lookup.timestamps.length, 0) +} + +export async function getCachedTotals( + userAddress: string, + version: VaultVersion, + startDate: string, + endDate: string +): Promise { + if (!isDatabaseEnabled()) { + debugLog('cache', 'skipping cached totals lookup because database is disabled') + return [] + } + + const pool = await getPool() + if (!pool) { + debugLog('cache', 'skipping cached totals lookup because database pool is unavailable') + return [] + } + + try { + const userAddressHash = getUserAddressCacheKey(userAddress) + debugLog('cache', 'loading cached totals', { userAddressHash, version, startDate, endDate }) + const result = await pool.query<{ date: string; usd_value: string }>( + `SELECT date::text AS date, usd_value FROM holdings_totals + WHERE user_address_hash = $1 AND version = $2 AND date >= $3 AND date <= $4 + ORDER BY date ASC`, + [userAddressHash, version, startDate, endDate] + ) + + const totals = result.rows.map((row) => ({ + date: row.date, + usdValue: parseFloat(row.usd_value) + })) + + debugLog('cache', 'loaded cached totals', { rows: totals.length }) + return totals + } catch (error) { + console.error('[Cache] Failed to get cached totals:', error) + debugError('cache', 'cached totals lookup failed', error) + return [] + } +} + +export async function saveCachedTotals( + userAddress: string, + version: VaultVersion, + totals: CachedTotal[] +): Promise { + if (!isDatabaseEnabled() || totals.length === 0) { + if (totals.length > 0) { + debugLog('cache', 'skipping cached totals save because database is disabled', { rows: totals.length }) + } + return + } + + const pool = await getPool() + if (!pool) { + debugLog('cache', 'skipping cached totals save because database pool is unavailable', { rows: totals.length }) + return + } + + try { + const userAddressHash = getUserAddressCacheKey(userAddress) + debugLog('cache', 'saving cached totals', { + userAddressHash, + version, + rows: totals.length + }) + const values: unknown[] = [] + const placeholders: string[] = [] + let paramIndex = 1 + + for (const total of totals) { + placeholders.push(`($${paramIndex}, $${paramIndex + 1}, $${paramIndex + 2}, $${paramIndex + 3})`) + values.push(userAddressHash, version, total.date, total.usdValue) + paramIndex += 4 + } + + const query = ` + INSERT INTO holdings_totals (user_address_hash, version, date, usd_value) + VALUES ${placeholders.join(', ')} + ON CONFLICT (user_address_hash, version, date) + DO UPDATE SET usd_value = EXCLUDED.usd_value, updated_at = NOW() + ` + + await pool.query(query, values) + debugLog('cache', 'saved cached totals', { rows: totals.length }) + } catch (error) { + console.error('[Cache] Failed to save totals:', error) + debugError('cache', 'cached totals save failed', error, { rows: totals.length }) + } +} + +export async function getCachedPrices( + tokenKeys: string[], + timestamps: number[] +): Promise>> { + return getCachedPricesForTokenTimestamps(tokenKeys.map((tokenKey) => ({ tokenKey, timestamps }))) +} + +export async function getCachedPricesForTokenTimestamps( + lookups: CachedPriceLookup[] +): Promise>> { + const result = new Map>() + const normalizedLookups = normalizeCachedPriceLookups(lookups) + const pricePoints = countCachedPriceLookupPoints(normalizedLookups) + + if (!isDatabaseEnabled() || normalizedLookups.length === 0 || pricePoints === 0) { + if (normalizedLookups.length > 0 && pricePoints > 0) { + debugLog('cache', 'skipping cached prices lookup because database is disabled', { + tokenKeys: normalizedLookups.length, + pricePoints + }) + } + return result + } + + const pool = await getPool() + if (!pool) { + debugLog('cache', 'skipping cached prices lookup because database pool is unavailable', { + tokenKeys: normalizedLookups.length, + pricePoints + }) + return result + } + + try { + debugLog('cache', 'loading cached prices', { tokenKeys: normalizedLookups.length, pricePoints }) + const lookupPairs = normalizedLookups.flatMap((lookup) => + lookup.timestamps.map((timestamp) => ({ tokenKey: lookup.tokenKey, timestamp })) + ) + + await chunkItems(lookupPairs, 5_000).reduce>(async (previousPromise, batch) => { + await previousPromise + + const values = batch.flatMap((pair) => [pair.tokenKey, pair.timestamp]) + const placeholders = batch.map((_, index) => `($${index * 2 + 1}::text, $${index * 2 + 2}::integer)`).join(', ') + + const query = ` + WITH requested(token_key, price_timestamp) AS ( + VALUES ${placeholders} + ) + SELECT token_prices.token_key, token_prices.timestamp, token_prices.price + FROM token_prices + INNER JOIN requested + ON requested.token_key = token_prices.token_key + AND requested.price_timestamp = token_prices.timestamp + ` + + const queryResult = await pool.query<{ token_key: string; timestamp: number; price: string }>(query, values) + + queryResult.rows.forEach((row) => { + const tokenPrices = result.get(row.token_key) ?? new Map() + tokenPrices.set(row.timestamp, parseFloat(row.price)) + result.set(row.token_key, tokenPrices) + }) + }, Promise.resolve()) + + const cachedPoints = Array.from(result.values()).reduce((total, priceMap) => total + priceMap.size, 0) + debugLog('cache', 'loaded cached prices', { + tokenKeys: result.size, + pricePoints: cachedPoints + }) + } catch (error) { + console.error('[Cache] Failed to get cached prices:', error) + debugError('cache', 'cached prices lookup failed', error, { + tokenKeys: normalizedLookups.length, + pricePoints + }) + } + + return result +} + +export async function getCachedPriceMisses( + tokenKeys: string[], + timestamps: number[] +): Promise>> { + return getCachedPriceMissesForTokenTimestamps(tokenKeys.map((tokenKey) => ({ tokenKey, timestamps }))) +} + +export async function getCachedPriceMissesForTokenTimestamps( + lookups: CachedPriceLookup[] +): Promise>> { + const result = new Map>() + const normalizedLookups = normalizeCachedPriceLookups(lookups) + const pricePoints = countCachedPriceLookupPoints(normalizedLookups) + + if (!isDatabaseEnabled() || normalizedLookups.length === 0 || pricePoints === 0) { + if (normalizedLookups.length > 0 && pricePoints > 0) { + debugLog('cache', 'skipping cached price misses lookup because database is disabled', { + tokenKeys: normalizedLookups.length, + pricePoints + }) + } + return result + } + + const pool = await getPool() + if (!pool) { + debugLog('cache', 'skipping cached price misses lookup because database pool is unavailable', { + tokenKeys: normalizedLookups.length, + pricePoints + }) + return result + } + + try { + debugLog('cache', 'loading cached price misses', { tokenKeys: normalizedLookups.length, pricePoints }) + const lookupPairs = normalizedLookups.flatMap((lookup) => + lookup.timestamps.map((timestamp) => ({ tokenKey: lookup.tokenKey, timestamp })) + ) + + await chunkItems(lookupPairs, 5_000).reduce>(async (previousPromise, batch) => { + await previousPromise + + const values = batch.flatMap((pair) => [pair.tokenKey, pair.timestamp]) + const placeholders = batch.map((_, index) => `($${index * 2 + 1}::text, $${index * 2 + 2}::integer)`).join(', ') + + const query = ` + WITH requested(token_key, price_timestamp) AS ( + VALUES ${placeholders} + ) + SELECT token_price_misses.token_key, token_price_misses.timestamp + FROM token_price_misses + INNER JOIN requested + ON requested.token_key = token_price_misses.token_key + AND requested.price_timestamp = token_price_misses.timestamp + WHERE token_price_misses.expires_at > NOW() + ` + + const queryResult = await pool.query<{ token_key: string; timestamp: number }>(query, values) + + queryResult.rows.forEach((row) => { + const tokenMisses = result.get(row.token_key) ?? new Set() + tokenMisses.add(row.timestamp) + result.set(row.token_key, tokenMisses) + }) + }, Promise.resolve()) + + const cachedMissPoints = Array.from(result.values()).reduce((total, timestampSet) => total + timestampSet.size, 0) + debugLog('cache', 'loaded cached price misses', { + tokenKeys: result.size, + missPoints: cachedMissPoints + }) + } catch (error) { + console.error('[Cache] Failed to get cached price misses:', error) + debugError('cache', 'cached price misses lookup failed', error, { + tokenKeys: normalizedLookups.length, + pricePoints + }) + } + + return result +} + +export async function saveCachedPrices(prices: CachedPrice[]): Promise { + if (!isDatabaseEnabled() || prices.length === 0) { + if (prices.length > 0) { + debugLog('cache', 'skipping cached prices save because database is disabled', { rows: prices.length }) + } + return + } + + const pool = await getPool() + if (!pool) { + debugLog('cache', 'skipping cached prices save because database pool is unavailable', { rows: prices.length }) + return + } + + try { + debugLog('cache', 'saving cached prices', { rows: prices.length }) + // Batch insert in chunks of 1000 + const BATCH_SIZE = 1000 + for (let i = 0; i < prices.length; i += BATCH_SIZE) { + const batch = prices.slice(i, i + BATCH_SIZE) + const values: unknown[] = [] + const placeholders: string[] = [] + let paramIndex = 1 + + for (const price of batch) { + placeholders.push(`($${paramIndex}, $${paramIndex + 1}, $${paramIndex + 2})`) + values.push(price.tokenKey, price.timestamp, price.price) + paramIndex += 3 + } + + const query = ` + INSERT INTO token_prices (token_key, timestamp, price) + VALUES ${placeholders.join(', ')} + ON CONFLICT (token_key, timestamp) DO NOTHING + ` + + await pool.query(query, values) + } + debugLog('cache', 'saved cached prices', { rows: prices.length }) + } catch (error) { + console.error('[Cache] Failed to save prices:', error) + debugError('cache', 'cached prices save failed', error, { rows: prices.length }) + } +} + +export async function saveCachedPriceMisses(priceMisses: CachedPriceMiss[]): Promise { + if (!isDatabaseEnabled() || priceMisses.length === 0) { + if (priceMisses.length > 0) { + debugLog('cache', 'skipping cached price misses save because database is disabled', { rows: priceMisses.length }) + } + return + } + + const pool = await getPool() + if (!pool) { + debugLog('cache', 'skipping cached price misses save because database pool is unavailable', { + rows: priceMisses.length + }) + return + } + + try { + debugLog('cache', 'saving cached price misses', { rows: priceMisses.length }) + const expiresAt = new Date(Date.now() + PRICE_MISS_TTL_MS) + const BATCH_SIZE = 1_000 + + for (let i = 0; i < priceMisses.length; i += BATCH_SIZE) { + const batch = priceMisses.slice(i, i + BATCH_SIZE) + const values: unknown[] = [] + const placeholders: string[] = [] + let paramIndex = 1 + + for (const priceMiss of batch) { + placeholders.push(`($${paramIndex}, $${paramIndex + 1}, $${paramIndex + 2})`) + values.push(priceMiss.tokenKey, priceMiss.timestamp, expiresAt) + paramIndex += 3 + } + + const query = ` + INSERT INTO token_price_misses (token_key, timestamp, expires_at) + VALUES ${placeholders.join(', ')} + ON CONFLICT (token_key, timestamp) + DO UPDATE SET expires_at = GREATEST(token_price_misses.expires_at, EXCLUDED.expires_at) + ` + + await pool.query(query, values) + } + + debugLog('cache', 'saved cached price misses', { rows: priceMisses.length }) + } catch (error) { + console.error('[Cache] Failed to save price misses:', error) + debugError('cache', 'cached price misses save failed', error, { rows: priceMisses.length }) + } +} + +export async function deleteStaleCache(): Promise { + if (!isDatabaseEnabled()) { + debugLog('cache', 'skipping stale cache deletion because database is disabled') + return 0 + } + + const pool = await getPool() + if (!pool) { + debugLog('cache', 'skipping stale cache deletion because database pool is unavailable') + return 0 + } + + try { + const [staleTotalsResult, expiredMissesResult, staleRateLimitsResult] = await Promise.all([ + pool.query(`DELETE FROM holdings_totals WHERE date < NOW() - INTERVAL '366 days'`), + pool.query(`DELETE FROM token_price_misses WHERE expires_at <= NOW()`), + pool.query(`DELETE FROM rate_limits WHERE window_start < NOW() - INTERVAL '1 day'`) + ]) + const deletedCount = + (staleTotalsResult.rowCount ?? 0) + (expiredMissesResult.rowCount ?? 0) + (staleRateLimitsResult.rowCount ?? 0) + console.log(`[Cache] Deleted ${deletedCount} stale cache rows`) + return deletedCount + } catch (error) { + console.error('[Cache] Failed to delete stale cache:', error) + debugError('cache', 'stale cache deletion failed', error) + return 0 + } +} + +export async function clearUserCache(userAddress: string, version?: VaultVersion): Promise { + if (!isDatabaseEnabled()) { + debugLog('cache', 'skipping user cache clear because database is disabled', { + userAddressHash: getUserAddressCacheKey(userAddress), + version: version ?? null + }) + return 0 + } + + const pool = await getPool() + if (!pool) { + debugLog('cache', 'skipping user cache clear because database pool is unavailable', { + userAddressHash: getUserAddressCacheKey(userAddress), + version: version ?? null + }) + return 0 + } + + try { + const userAddressHash = getUserAddressCacheKey(userAddress) + const result = version + ? await pool.query('DELETE FROM holdings_totals WHERE user_address_hash = $1 AND version = $2', [ + userAddressHash, + version + ]) + : await pool.query('DELETE FROM holdings_totals WHERE user_address_hash = $1', [userAddressHash]) + const deletedCount = result.rowCount ?? 0 + console.log(`[Cache] Cleared ${deletedCount} cached rows for user ${userAddress}${version ? ` (${version})` : ''}`) + return deletedCount + } catch (error) { + console.error('[Cache] Failed to clear user cache:', error) + debugError('cache', 'user cache clear failed', error, { + userAddressHash: getUserAddressCacheKey(userAddress), + version: version ?? null + }) + return 0 + } +} + +export interface VaultIdentifier { + address: string + chainId: number +} + +export async function invalidateVaults(vaults: VaultIdentifier[]): Promise { + if (!isDatabaseEnabled() || vaults.length === 0) { + if (vaults.length > 0) { + debugLog('cache', 'skipping vault invalidation because database is disabled', { vaults: vaults.length }) + } + return 0 + } + + const pool = await getPool() + if (!pool) { + debugLog('cache', 'skipping vault invalidation because database pool is unavailable', { vaults: vaults.length }) + return 0 + } + + try { + const values: unknown[] = [] + const placeholders: string[] = [] + let paramIndex = 1 + + for (const vault of vaults) { + placeholders.push(`($${paramIndex}, $${paramIndex + 1}, NOW())`) + values.push(vault.address.toLowerCase(), vault.chainId) + paramIndex += 2 + } + + const query = ` + INSERT INTO vault_invalidations (vault_address, chain_id, invalidated_at) + VALUES ${placeholders.join(', ')} + ON CONFLICT (vault_address, chain_id) + DO UPDATE SET invalidated_at = NOW() + ` + + await pool.query(query, values) + console.log(`[Cache] Invalidated ${vaults.length} vaults`) + return vaults.length + } catch (error) { + console.error('[Cache] Failed to invalidate vaults:', error) + debugError('cache', 'vault invalidation failed', error, { vaults: vaults.length }) + return 0 + } +} + +export async function checkCacheStaleness( + vaults: VaultIdentifier[], + cacheOldestTimestamp: Date | null +): Promise { + if (!isDatabaseEnabled() || vaults.length === 0 || !cacheOldestTimestamp) { + if (vaults.length > 0 && cacheOldestTimestamp !== null) { + debugLog('cache', 'skipping cache staleness check because database is disabled', { vaults: vaults.length }) + } + return false + } + + const pool = await getPool() + if (!pool) { + debugLog('cache', 'skipping cache staleness check because database pool is unavailable', { vaults: vaults.length }) + return false + } + + try { + debugLog('cache', 'checking cache staleness', { + vaults: vaults.length, + cacheOldestTimestamp: cacheOldestTimestamp.toISOString() + }) + // Build WHERE clause for vault pairs + const conditions: string[] = [] + const values: unknown[] = [] + let paramIndex = 1 + + for (const vault of vaults) { + conditions.push(`(vault_address = $${paramIndex} AND chain_id = $${paramIndex + 1})`) + values.push(vault.address.toLowerCase(), vault.chainId) + paramIndex += 2 + } + + const query = ` + SELECT MAX(invalidated_at) as latest_invalidation + FROM vault_invalidations + WHERE ${conditions.join(' OR ')} + ` + + const result = await pool.query<{ latest_invalidation: Date | null }>(query, values) + const latestInvalidation = result.rows[0]?.latest_invalidation + + if (!latestInvalidation) { + return false // No invalidations recorded for these vaults + } + + const isStale = latestInvalidation > cacheOldestTimestamp + debugLog('cache', 'checked 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) { + console.error('[Cache] Failed to check cache staleness:', error) + debugError('cache', 'cache staleness check failed', error, { vaults: vaults.length }) + return false // Fail-open: proceed with cached data + } +} + +export interface CachedTotalsResult { + totals: CachedTotal[] + oldestUpdatedAt: Date | null +} + +export async function getCachedTotalsWithTimestamp( + userAddress: string, + version: VaultVersion, + startDate: string, + endDate: string +): Promise { + if (!isDatabaseEnabled()) { + debugLog('cache', 'skipping cached totals with timestamp lookup because database is disabled') + return { totals: [], oldestUpdatedAt: null } + } + + const pool = await getPool() + if (!pool) { + debugLog('cache', 'skipping cached totals with timestamp lookup because database pool is unavailable') + return { totals: [], oldestUpdatedAt: null } + } + + try { + const userAddressHash = getUserAddressCacheKey(userAddress) + debugLog('cache', 'loading cached totals with timestamps', { + userAddressHash, + version, + startDate, + endDate + }) + const result = await pool.query<{ date: string; usd_value: string; updated_at: Date }>( + `SELECT date::text AS date, usd_value, updated_at FROM holdings_totals + WHERE user_address_hash = $1 AND version = $2 AND date >= $3 AND date <= $4 + ORDER BY date ASC`, + [userAddressHash, version, startDate, endDate] + ) + + const totals = result.rows.map((row) => ({ + date: row.date, + usdValue: parseFloat(row.usd_value) + })) + + const oldestUpdatedAt = + result.rows.length > 0 + ? result.rows.reduce((min, row) => (row.updated_at < min ? row.updated_at : min), result.rows[0].updated_at) + : null + + debugLog('cache', 'loaded cached totals with timestamps', { + rows: totals.length, + oldestUpdatedAt: oldestUpdatedAt?.toISOString() ?? null + }) + return { totals, oldestUpdatedAt } + } catch (error) { + console.error('[Cache] Failed to get cached totals with timestamp:', error) + debugError('cache', 'cached totals with timestamp lookup failed', error, { + userAddressHash: getUserAddressCacheKey(userAddress), + version, + startDate, + endDate + }) + return { totals: [], oldestUpdatedAt: null } + } +} diff --git a/api/lib/holdings/services/cow.test.ts b/api/lib/holdings/services/cow.test.ts new file mode 100644 index 000000000..58f4f6136 --- /dev/null +++ b/api/lib/holdings/services/cow.test.ts @@ -0,0 +1,184 @@ +import { encodeAbiParameters, encodeEventTopics, parseAbiItem } from 'viem' +import { describe, expect, it } from 'vitest' +import type { TransferEvent, VaultMetadata } from '../types' +import { enrichRawPnlEventsWithCowTradeAcquisitions, type RpcTransactionReceipt } from './cow' +import { buildRawPnlEvents, processRawPnlEvents } from './pnl' + +const USER = '0x2222222222222222222222222222222222222222' +const SOLVER = '0x6bf97afe2d2c790999cded2a8523009eb8a0823f' +const GPV2_SETTLEMENT = '0x9008d19f58aabd9ed0d60971565aa8510560ab41' +const DAI = '0x6b175474e89094c44da98b954eedeac495271d0f' +const COW_VAULT = '0x27b5739e22ad9033bcbf192059122d163b60349d' +const ASSET_TOKEN = '0xfcc5c47be19d06bf83eb04298b026f81069ff65b' +const FAMILY_KEY = `1:${COW_VAULT}` +const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000' +const TRANSFER_EVENT = parseAbiItem('event Transfer(address indexed from, address indexed to, uint256 value)') +const TRADE_EVENT = parseAbiItem( + 'event Trade(address indexed owner, address sellToken, address buyToken, uint256 sellAmount, uint256 buyAmount, uint256 feeAmount, bytes orderUid)' +) + +function createTransferEvent(overrides: Partial): TransferEvent { + return { + id: 'transfer', + vaultAddress: COW_VAULT, + chainId: 1, + blockNumber: 1, + blockTimestamp: 100, + logIndex: 1, + transactionHash: '0xcow', + transactionFrom: SOLVER, + sender: GPV2_SETTLEMENT, + receiver: USER, + value: '500', + ...overrides + } +} + +function createMetadataMap(): Map { + return new Map([ + [ + FAMILY_KEY, + { + address: COW_VAULT, + chainId: 1, + token: { + address: ASSET_TOKEN, + symbol: 'yCRV', + decimals: 18 + }, + decimals: 18 + } + ] + ]) +} + +function createTransferLog( + address: string, + from: string, + to: string, + value: bigint, + logIndex: number +): RpcTransactionReceipt['logs'][number] { + return { + address, + topics: encodeEventTopics({ + abi: [TRANSFER_EVENT], + eventName: 'Transfer', + args: { from, to } + }) as string[], + data: encodeAbiParameters([{ type: 'uint256' }], [value]), + logIndex: `0x${logIndex.toString(16)}` + } +} + +function createTradeLog(buyAmount: bigint): RpcTransactionReceipt['logs'][number] { + return { + address: GPV2_SETTLEMENT, + topics: encodeEventTopics({ + abi: [TRADE_EVENT], + eventName: 'Trade', + args: { owner: USER } + }) as string[], + data: encodeAbiParameters( + [ + { type: 'address', name: 'sellToken' }, + { type: 'address', name: 'buyToken' }, + { type: 'uint256', name: 'sellAmount' }, + { type: 'uint256', name: 'buyAmount' }, + { type: 'uint256', name: 'feeAmount' }, + { type: 'bytes', name: 'orderUid' } + ], + [DAI, COW_VAULT, 250n, buyAmount, 0n, '0x1234'] + ), + logIndex: '0x0' + } +} + +function createReceipt(buyAmount: bigint): RpcTransactionReceipt { + return { + logs: [ + createTradeLog(buyAmount), + createTransferLog(ASSET_TOKEN, GPV2_SETTLEMENT, COW_VAULT, 800n, 1), + createTransferLog(COW_VAULT, ZERO_ADDRESS, GPV2_SETTLEMENT, 1000n, 2), + createTransferLog(COW_VAULT, GPV2_SETTLEMENT, USER, buyAmount, 3) + ] + } +} + +function createRawCowTransferEvents() { + const transferIn = createTransferEvent({ + id: 'cow-transfer-in', + blockNumber: 2, + blockTimestamp: 200, + logIndex: 3, + transactionHash: '0xcow', + value: '500' + }) + const txMint = createTransferEvent({ + id: 'cow-mint', + sender: ZERO_ADDRESS, + receiver: GPV2_SETTLEMENT, + blockNumber: 2, + blockTimestamp: 200, + logIndex: 2, + transactionHash: '0xcow', + value: '1000' + }) + + return buildRawPnlEvents({ + addressEvents: { + deposits: [], + withdrawals: [], + transfersIn: [transferIn], + transfersOut: [] + }, + transactionEvents: { + deposits: [], + withdrawals: [], + transfers: [txMint, transferIn] + } + }) +} + +describe('enrichRawPnlEventsWithCowTradeAcquisitions', () => { + it('adds a tx-scoped synthetic deposit that turns a CoW settlement transfer-in into known basis', async () => { + const enrichedEvents = await enrichRawPnlEventsWithCowTradeAcquisitions(createRawCowTransferEvents(), USER, { + fetchMetadata: async () => createMetadataMap(), + fetchReceipt: async () => createReceipt(500n) + }) + + expect( + enrichedEvents.some( + (event) => + event.kind === 'deposit' && + event.id.startsWith('cow-trade:') && + event.scopes.address === false && + event.scopes.tx === true && + event.assets === 400n && + event.shares === 500n + ) + ).toBe(true) + + const ledger = processRawPnlEvents(enrichedEvents, USER).get(FAMILY_KEY) + + expect(ledger?.vaultLots).toEqual([{ shares: 500n, costBasis: 400n, acquiredAt: 200 }]) + expect(ledger?.unknownCostBasisTransferInCount).toBe(0) + expect(ledger?.totalDepositedAssets).toBe(400n) + expect(ledger?.eventCounts.underlyingDeposits).toBe(1) + }) + + it('skips enrichment when the CoW trade buy amount does not match the received share transfer', async () => { + const enrichedEvents = await enrichRawPnlEventsWithCowTradeAcquisitions(createRawCowTransferEvents(), USER, { + fetchMetadata: async () => createMetadataMap(), + fetchReceipt: async () => createReceipt(400n) + }) + + expect(enrichedEvents.some((event) => event.kind === 'deposit' && event.id.startsWith('cow-trade:'))).toBe(false) + + const ledger = processRawPnlEvents(enrichedEvents, USER).get(FAMILY_KEY) + + expect(ledger?.vaultLots).toEqual([{ shares: 500n, costBasis: null, acquiredAt: 200 }]) + expect(ledger?.unknownCostBasisTransferInCount).toBe(1) + expect(ledger?.unknownCostBasisTransferInShares).toBe(500n) + }) +}) diff --git a/api/lib/holdings/services/cow.ts b/api/lib/holdings/services/cow.ts new file mode 100644 index 000000000..6e6b05978 --- /dev/null +++ b/api/lib/holdings/services/cow.ts @@ -0,0 +1,413 @@ +import { decodeEventLog, type Hex, parseAbiItem } from 'viem' +import { config } from '../config' +import type { VaultMetadata } from '../types' +import { debugError, debugLog } from './debug' +import { lowerCaseAddress, toVaultKey, ZERO, ZERO_ADDRESS } from './pnlShared' +import type { TRawPnlEvent } from './pnlTypes' +import { fetchMultipleVaultsMetadata } from './vaults' + +const MAINNET_CHAIN_ID = 1 +const GPV2_SETTLEMENT_ADDRESS = '0x9008d19f58aabd9ed0d60971565aa8510560ab41' +const DEFAULT_TIMEOUT_MS = 4_000 +const DEFAULT_MAX_RETRIES = 1 +const TRANSFER_EVENT = parseAbiItem('event Transfer(address indexed from, address indexed to, uint256 value)') +const TRADE_EVENT = parseAbiItem( + 'event Trade(address indexed owner, address sellToken, address buyToken, uint256 sellAmount, uint256 buyAmount, uint256 feeAmount, bytes orderUid)' +) + +type TFetchMetadataFn = typeof fetchMultipleVaultsMetadata +type TFetchReceiptFn = (transactionHash: string) => Promise + +type TCowTradeCandidate = { + chainId: number + familyVaultAddress: string + transactionHash: string + transactionFrom: string + blockNumber: number + blockTimestamp: number + transferInShares: bigint +} + +type TDecodedTransferLog = { + address: string + from: string + to: string + value: bigint +} + +type TDecodedTradeLog = { + owner: string + sellToken: string + buyToken: string + sellAmount: bigint + buyAmount: bigint + logIndex: number +} + +export interface RpcReceiptLog { + address: string + data: string + topics: string[] + logIndex: string | null +} + +export interface RpcTransactionReceipt { + logs: RpcReceiptLog[] | null +} + +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 groupEventsByTransaction(events: TRawPnlEvent[]): TRawPnlEvent[][] { + return Array.from( + events.reduce>((groups, event) => { + const transactionKey = `${event.chainId}:${event.transactionHash}` + const nextEvents = [...(groups.get(transactionKey) ?? []), event] + groups.set(transactionKey, nextEvents) + return groups + }, new Map()) + ).map(([_key, txEvents]) => [...txEvents].sort(compareRawEvents)) +} + +function groupTransactionEventsByFamily(txEvents: TRawPnlEvent[]): TRawPnlEvent[][] { + return Array.from( + txEvents.reduce>((groups, event) => { + const familyKey = toVaultKey(event.chainId, event.familyVaultAddress) + const nextEvents = [...(groups.get(familyKey) ?? []), event] + groups.set(familyKey, nextEvents) + return groups + }, new Map()) + ).map(([_key, familyEvents]) => [...familyEvents].sort(compareRawEvents)) +} + +function sumValues(values: bigint[]): bigint { + return values.reduce((total, value) => total + value, ZERO) +} + +function parseHexInteger(value: string | null): number { + return value === null ? 0 : Number.parseInt(value, 16) +} + +function decodeTransferLog(log: RpcReceiptLog): TDecodedTransferLog | null { + try { + const decoded = decodeEventLog({ + abi: [TRANSFER_EVENT], + data: log.data as Hex, + topics: log.topics as Hex[] + }) + const args = decoded.args as { + from: string + to: string + value: bigint + } + + return { + address: lowerCaseAddress(log.address), + from: lowerCaseAddress(args.from), + to: lowerCaseAddress(args.to), + value: args.value + } + } catch { + return null + } +} + +function decodeTradeLog(log: RpcReceiptLog): TDecodedTradeLog | null { + try { + const decoded = decodeEventLog({ + abi: [TRADE_EVENT], + data: log.data as Hex, + topics: log.topics as Hex[] + }) + const args = decoded.args as { + owner: string + sellToken: string + buyToken: string + sellAmount: bigint + buyAmount: bigint + } + + return { + owner: lowerCaseAddress(args.owner), + sellToken: lowerCaseAddress(args.sellToken), + buyToken: lowerCaseAddress(args.buyToken), + sellAmount: args.sellAmount, + buyAmount: args.buyAmount, + logIndex: parseHexInteger(log.logIndex) + } + } catch { + return null + } +} + +async function fetchTransactionReceipt(transactionHash: string, attempt = 0): Promise { + try { + const response = await fetch(config.ethereumRpcUrl, { + method: 'POST', + headers: { + 'content-type': 'application/json' + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'eth_getTransactionReceipt', + params: [transactionHash] + }), + signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS) + }) + + if (!response.ok) { + throw new Error(`RPC receipt request failed: ${response.status}`) + } + + const payload = (await response.json()) as { + result: RpcTransactionReceipt | null + error?: { + message?: string + } + } + + if (payload.error) { + throw new Error(payload.error.message ?? 'RPC receipt request returned an error') + } + + return payload.result + } catch (error) { + if (attempt >= DEFAULT_MAX_RETRIES) { + debugError('cow', 'failed to fetch CoW settlement receipt', error, { transactionHash, attempt: attempt + 1 }) + return null + } + + debugError('cow', 'retrying CoW settlement receipt fetch', error, { + transactionHash, + nextAttempt: attempt + 2 + }) + return fetchTransactionReceipt(transactionHash, attempt + 1) + } +} + +function getCowTradeCandidates(rawEvents: TRawPnlEvent[], userAddress: string): TCowTradeCandidate[] { + return groupEventsByTransaction(rawEvents).flatMap((txEvents) => + groupTransactionEventsByFamily(txEvents).flatMap((familyEvents) => { + const familyVaultAddress = familyEvents[0]?.familyVaultAddress + const chainId = familyEvents[0]?.chainId + const transactionHash = familyEvents[0]?.transactionHash + const transactionFrom = familyEvents[0]?.transactionFrom + const blockNumber = familyEvents[0]?.blockNumber ?? 0 + const blockTimestamp = familyEvents[0]?.blockTimestamp ?? 0 + const hasDirectFamilyDeposit = familyEvents.some( + (event) => event.kind === 'deposit' && event.vaultAddress === familyVaultAddress + ) + const settlementTransfersIn = familyEvents.filter( + (event): event is Extract => + event.kind === 'transfer' && + event.scopes.address && + event.vaultAddress === familyVaultAddress && + event.sender === GPV2_SETTLEMENT_ADDRESS && + event.receiver === userAddress + ) + + if ( + chainId !== MAINNET_CHAIN_ID || + !familyVaultAddress || + !transactionHash || + !transactionFrom || + hasDirectFamilyDeposit || + settlementTransfersIn.length === 0 + ) { + return [] + } + + return [ + { + chainId, + familyVaultAddress, + transactionHash, + transactionFrom, + blockNumber, + blockTimestamp, + transferInShares: sumValues(settlementTransfersIn.map((event) => event.shares)) + } + ] + }) + ) +} + +function buildSyntheticCowTradeDeposit( + candidate: TCowTradeCandidate, + metadata: VaultMetadata, + receipt: RpcTransactionReceipt, + userAddress: string +): Extract | null { + const decodedTrades = (receipt.logs ?? []) + .filter((log) => lowerCaseAddress(log.address) === GPV2_SETTLEMENT_ADDRESS) + .map(decodeTradeLog) + .filter( + (trade): trade is TDecodedTradeLog => + trade !== null && trade.owner === userAddress && trade.buyToken === candidate.familyVaultAddress + ) + + const boughtShares = sumValues(decodedTrades.map((trade) => trade.buyAmount)) + + if (boughtShares === ZERO || boughtShares !== candidate.transferInShares) { + return null + } + + const decodedTransfers = (receipt.logs ?? []) + .map(decodeTransferLog) + .filter((log): log is TDecodedTransferLog => log !== null) + const mintedShares = sumValues( + decodedTransfers + .filter( + (log) => + log.address === candidate.familyVaultAddress && + log.from === ZERO_ADDRESS && + log.to === GPV2_SETTLEMENT_ADDRESS + ) + .map((log) => log.value) + ) + const depositedAssets = sumValues( + decodedTransfers + .filter( + (log) => log.address === lowerCaseAddress(metadata.token.address) && log.to === candidate.familyVaultAddress + ) + .map((log) => log.value) + ) + + if (mintedShares === ZERO || depositedAssets === ZERO || boughtShares > mintedShares) { + return null + } + + const assets = (depositedAssets * boughtShares) / mintedShares + + if (assets === ZERO) { + return null + } + + const sellAmount = sumValues(decodedTrades.map((trade) => trade.sellAmount)) + const logIndex = decodedTrades.reduce( + (minimum, trade) => (trade.logIndex < minimum ? trade.logIndex : minimum), + decodedTrades[0]?.logIndex ?? 0 + ) + + debugLog('cow', 'synthesized CoW settlement acquisition', { + transactionHash: candidate.transactionHash, + familyVaultAddress: candidate.familyVaultAddress, + owner: userAddress, + sellToken: decodedTrades[0]?.sellToken ?? null, + sellAmount: sellAmount.toString(), + boughtShares: boughtShares.toString(), + syntheticAssets: assets.toString(), + totalMintedShares: mintedShares.toString(), + totalDepositedAssets: depositedAssets.toString() + }) + + return { + kind: 'deposit', + id: `cow-trade:${candidate.chainId}:${candidate.transactionHash}:${candidate.familyVaultAddress}:${userAddress}`, + chainId: candidate.chainId, + vaultAddress: candidate.familyVaultAddress, + familyVaultAddress: candidate.familyVaultAddress, + isStakingVault: false, + blockNumber: candidate.blockNumber, + blockTimestamp: candidate.blockTimestamp, + logIndex, + transactionHash: candidate.transactionHash, + transactionFrom: candidate.transactionFrom, + owner: userAddress, + sender: GPV2_SETTLEMENT_ADDRESS, + shares: boughtShares, + assets, + scopes: { + address: false, + tx: true + } + } +} + +export async function enrichRawPnlEventsWithCowTradeAcquisitions( + rawEvents: TRawPnlEvent[], + userAddress: string, + dependencies?: { + fetchMetadata?: TFetchMetadataFn + fetchReceipt?: TFetchReceiptFn + } +): Promise { + const userAddressLower = lowerCaseAddress(userAddress) + const candidates = getCowTradeCandidates(rawEvents, userAddressLower) + + if (candidates.length === 0) { + return rawEvents + } + + try { + const fetchMetadata = dependencies?.fetchMetadata ?? fetchMultipleVaultsMetadata + const metadataRequests = Array.from( + candidates.reduce>((requests, candidate) => { + requests.set(toVaultKey(candidate.chainId, candidate.familyVaultAddress), { + chainId: candidate.chainId, + vaultAddress: candidate.familyVaultAddress + }) + return requests + }, new Map()) + ).map(([_key, request]) => request) + const metadataByVaultKey = await fetchMetadata(metadataRequests) + const candidatesWithMetadata = candidates.flatMap((candidate) => { + const metadata = metadataByVaultKey.get(toVaultKey(candidate.chainId, candidate.familyVaultAddress)) + return metadata ? [{ ...candidate, metadata }] : [] + }) + + if (candidatesWithMetadata.length === 0) { + return rawEvents + } + + const fetchReceipt = dependencies?.fetchReceipt ?? fetchTransactionReceipt + const receiptEntries = await Promise.all( + Array.from(new Set(candidatesWithMetadata.map((candidate) => candidate.transactionHash.toLowerCase()))).map( + async (transactionHash) => [transactionHash, await fetchReceipt(transactionHash)] as const + ) + ) + const receiptsByTransactionHash = receiptEntries.reduce>((receipts, entry) => { + const [transactionHash, receipt] = entry + + if (receipt !== null) { + receipts.set(transactionHash, receipt) + } + + return receipts + }, new Map()) + const syntheticDeposits = candidatesWithMetadata.flatMap((candidate) => { + const receipt = receiptsByTransactionHash.get(candidate.transactionHash.toLowerCase()) + + if (!receipt) { + return [] + } + + const syntheticDeposit = buildSyntheticCowTradeDeposit(candidate, candidate.metadata, receipt, userAddressLower) + + return syntheticDeposit ? [syntheticDeposit] : [] + }) + + if (syntheticDeposits.length === 0) { + return rawEvents + } + + debugLog('cow', 'completed CoW settlement enrichment', { + candidates: candidatesWithMetadata.length, + synthesizedDeposits: syntheticDeposits.length + }) + + return [...rawEvents, ...syntheticDeposits].sort(compareRawEvents) + } catch (error) { + debugError('cow', 'failed CoW settlement enrichment, continuing with raw events', error, { + candidateCount: candidates.length + }) + return rawEvents + } +} diff --git a/api/lib/holdings/services/debug.ts b/api/lib/holdings/services/debug.ts new file mode 100644 index 000000000..eb045f66c --- /dev/null +++ b/api/lib/holdings/services/debug.ts @@ -0,0 +1,112 @@ +import { AsyncLocalStorage } from 'node:async_hooks' + +export interface HoldingsDebugContext { + enabled: boolean + requestId: string + route: 'history' | 'breakdown' | 'pnl' + address: string + startedAt: number + lotsEnabled: boolean + vaultFilter: string | null + txFilter: 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' | 'pnl', + address: string, + enabled: boolean, + options?: { + lotsEnabled?: boolean + vaultFilter?: string | null + txFilter?: 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 + } +} + +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?.enabled) { + return + } + + const elapsedMs = Date.now() - context.startedAt + console.log(`[HoldingsDebug][${context.requestId}][+${elapsedMs}ms][${scope}] ${message}${formatPayload(payload)}`) +} + +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..228e51844 --- /dev/null +++ b/api/lib/holdings/services/defillama.test.ts @@ -0,0 +1,678 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { DefiLlamaBatchResponse } from '../types' +import { getCachedPriceMisses, getCachedPrices, saveCachedPriceMisses, saveCachedPrices } from './cache' +import { + fetchHistoricalPrices, + fetchHistoricalPricesForTokenTimestamps, + getChainPrefix, + getPriceAtTimestamp, + parseDefiLlamaResponse +} from './defillama' + +vi.mock('./cache', () => { + const getCachedPriceMisses = vi.fn(async () => new Map()) + const getCachedPrices = vi.fn(async () => new Map()) + + return { + getCachedPriceMisses, + getCachedPriceMissesForTokenTimestamps: getCachedPriceMisses, + getCachedPrices, + getCachedPricesForTokenTimestamps: getCachedPrices, + saveCachedPriceMisses: vi.fn(async () => {}), + saveCachedPrices: vi.fn(async () => {}) + } +}) + +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() + vi.mocked(getCachedPriceMisses).mockResolvedValue(new Map()) + vi.mocked(getCachedPrices).mockResolvedValue(new Map()) + vi.mocked(saveCachedPriceMisses).mockResolvedValue() + vi.mocked(saveCachedPrices).mockResolvedValue() +}) + +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('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('fetches only missing token-timestamp pairs instead of the union across missing tokens', async () => { + const usdcKey = 'ethereum:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48' + const daiKey = 'ethereum:0x6b175474e89094c44da98b954eedeac495271d0f' + vi.mocked(getCachedPrices).mockResolvedValue( + new Map([ + [usdcKey, new Map([[1700000000, 1]])], + [daiKey, new Map([[1700003600, 1]])] + ]) + ) + + const fetchStub = vi.fn().mockResolvedValue( + createBatchResponse({ + coins: { + [usdcKey]: { + symbol: 'USDC', + prices: [{ timestamp: 1700003600, price: 1.001, confidence: 0.99 }] + }, + [daiKey]: { + symbol: 'DAI', + prices: [{ timestamp: 1700000000, price: 0.999, 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]: [1700003600], + [daiKey]: [1700000000] + }) + 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('skips token-timestamp pairs already cached as misses', async () => { + const usdcKey = 'ethereum:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48' + vi.mocked(getCachedPriceMisses).mockResolvedValue(new Map([[usdcKey, new Set([1700000000])]])) + const fetchStub = vi.fn() + + vi.stubGlobal('fetch', fetchStub) + + const prices = await fetchHistoricalPrices( + [{ chainId: 1, address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' }], + [1700000000] + ) + + expect(fetchStub).not.toHaveBeenCalled() + expect(prices.get(usdcKey)?.size).toBe(0) + }) + + 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 caches 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) + expect(vi.mocked(saveCachedPrices)).toHaveBeenCalledWith([ + { tokenKey: usdcKey, timestamp: 1700003600, price: 0.999 } + ]) + expect(vi.mocked(saveCachedPriceMisses)).toHaveBeenCalledWith([{ tokenKey: usdcKey, timestamp: 1700000000 }]) + }) + + it('caches exact token-timestamp misses 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) + expect(vi.mocked(saveCachedPriceMisses)).toHaveBeenCalledWith([ + { tokenKey: usdcKey, timestamp: 1700000000 }, + { tokenKey: usdcKey, timestamp: 1700003600 } + ]) + }) + + it('does not read or write cache for uncached timestamp requests', 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], + uncachedTimestamps: [1700003600] + } + ]) + + expect(getCachedPrices).toHaveBeenCalledWith([{ tokenKey, timestamps: [1700000000] }]) + expect(getCachedPriceMisses).toHaveBeenCalledWith([{ tokenKey, timestamps: [1700000000] }]) + expect(saveCachedPrices).toHaveBeenCalledWith([{ tokenKey, timestamp: 1700000000, price: 1 }]) + expect(saveCachedPriceMisses).not.toHaveBeenCalled() + expect(prices.get(tokenKey)?.get(1700003600)).toBe(1.001) + }) + + it('records misses for timestamps that only have future quotes available', 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) + expect(vi.mocked(saveCachedPrices)).toHaveBeenCalledWith([ + { tokenKey: usdcKey, timestamp: 1700003600, price: 1.001 } + ]) + expect(vi.mocked(saveCachedPriceMisses)).toHaveBeenCalledWith([{ tokenKey: usdcKey, timestamp: 1700000000 }]) + }) +}) diff --git a/api/lib/holdings/services/defillama.ts b/api/lib/holdings/services/defillama.ts new file mode 100644 index 000000000..d69638ca1 --- /dev/null +++ b/api/lib/holdings/services/defillama.ts @@ -0,0 +1,865 @@ +import { config } from '../config' +import { type DefiLlamaBatchResponse, SUPPORTED_CHAINS } from '../types' +import { + type CachedPrice, + type CachedPriceMiss, + getCachedPriceMissesForTokenTimestamps, + getCachedPricesForTokenTimestamps, + saveCachedPriceMisses, + saveCachedPrices +} from './cache' +import { debugError, debugLog } from './debug' + +type TDefiLlamaError = 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_PRO_TIMEOUT_MS = 12_000 +const DEFAULT_MAX_RETRIES = 2 +const DEFAULT_RETRY_DELAY_MS = 200 +const DEFAULT_MAX_REQUEST_URL_LENGTH = 3_500 +const MAX_REQUESTED_PRICE_DISTANCE_SECONDS = 60 * 60 +const SPLITTABLE_GET_STATUS_CODES = new Set([414, 431, 505]) + +type TCoinRequest = { chain: string; address: string; timestamps: number[] } +type TMissingPriceFetchGroup = { + label: 'cacheable' | 'uncached' + cacheResults: boolean + coins: TCoinRequest[] +} +type TDefiLlamaFetchTuning = { + 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' +} + +export type THistoricalPriceRequest = { + chainId: number + address: string + timestamps: number[] + uncachedTimestamps?: number[] +} + +export function getChainPrefix(chainId: number): string { + const chain = SUPPORTED_CHAINS.find((c) => c.id === chainId) + return chain?.defillamaPrefix || 'ethereum' +} + +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 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) +} + +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.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 materializeRequestedPrices( + coins: TCoinRequest[], + fetchedPrices: Map> +): CachedPrice[] { + 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 = getPriceAtTimestampWithinTolerance(fetchedPriceMap, timestamp) + return matchedPrice === null + ? null + : { + tokenKey, + timestamp, + price: matchedPrice.price + } + }) + .filter((entry): entry is CachedPrice => entry !== null && entry.price > 0) + }) +} + +function materializeRequestedPriceMisses( + coins: TCoinRequest[], + fetchedPrices: Map> +): CachedPriceMiss[] { + return coins.flatMap((coin) => { + const tokenKey = `${coin.chain}:${coin.address.toLowerCase()}` + const fetchedPriceMap = fetchedPrices.get(tokenKey) ?? new Map() + + if (!fetchedPriceMap || fetchedPriceMap.size === 0) { + return coin.timestamps.map((timestamp) => ({ + tokenKey, + timestamp + })) + } + + return coin.timestamps + .filter((timestamp) => getPriceAtTimestampWithinTolerance(fetchedPriceMap, timestamp) === null) + .map((timestamp) => ({ + tokenKey, + timestamp + })) + }) +} + +function toTimestampCacheKey(tokenKey: string, timestamp: number): string { + return `${tokenKey}:${timestamp}` +} + +function filterCacheableTimestamps( + timestamps: number[], + uncachedTimestampKeys: Set, + tokenKey: string +): number[] { + return timestamps.filter((timestamp) => !uncachedTimestampKeys.has(toTimestampCacheKey(tokenKey, timestamp))) +} + +function splitMissingCoinsByCacheability( + coins: TCoinRequest[], + missingByToken: Map, + uncachedTimestampKeys: Set +): TMissingPriceFetchGroup[] { + const groups = coins.reduce>( + (result, coin) => { + const tokenKey = `${coin.chain}:${coin.address.toLowerCase()}` + const missingTimestamps = missingByToken.get(tokenKey) ?? [] + const cacheableTimestamps = missingTimestamps.filter( + (timestamp) => !uncachedTimestampKeys.has(toTimestampCacheKey(tokenKey, timestamp)) + ) + const uncachedTimestamps = missingTimestamps.filter((timestamp) => + uncachedTimestampKeys.has(toTimestampCacheKey(tokenKey, timestamp)) + ) + + if (cacheableTimestamps.length > 0) { + result.cacheable.coins.push({ + chain: coin.chain, + address: coin.address, + timestamps: cacheableTimestamps + }) + } + + if (uncachedTimestamps.length > 0) { + result.uncached.coins.push({ + chain: coin.chain, + address: coin.address, + timestamps: uncachedTimestamps + }) + } + + return result + }, + { + cacheable: { label: 'cacheable', cacheResults: true, coins: [] }, + uncached: { label: 'uncached', cacheResults: false, coins: [] } + } + ) + + return [groups.cacheable, groups.uncached].filter((group) => group.coins.length > 0) +} + +function buildCoinsParam(coins: TCoinRequest[]): Record { + return coins.reduce>((accumulator, coin) => { + accumulator[`${coin.chain}:${coin.address.toLowerCase()}`] = coin.timestamps + return accumulator + }, {}) +} + +export function buildBatchHistoricalUrl(coins: TCoinRequest[]): string { + const encodedCoins = encodeURIComponent(JSON.stringify(buildCoinsParam(coins))) + return `${config.defillamaBaseUrl}/batchHistorical?coins=${encodedCoins}` +} + +function buildProBatchHistoricalGetUrl(coins: TCoinRequest[]): string { + const encodedCoins = encodeURIComponent(JSON.stringify(buildCoinsParam(coins))) + return `${config.defillamaProBaseUrl}/${config.defillamaApiKey}/coins/batchHistorical?coins=${encodedCoins}` +} + +function buildBatchHistoricalRequests(coins: TCoinRequest[]): TDefiLlamaBatchRequest[] { + if (config.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.at(-1) + + return { + firstTimestamp: uniqueTimestamps[0] ?? null, + lastTimestamp: uniqueTimestamps.at(-1) ?? null, + firstToken: firstCoin ? abbreviateTokenAddress(firstCoin.address) : null, + lastToken: lastCoin ? abbreviateTokenAddress(lastCoin.address) : null + } +} + +function isSplittableGetError(error: unknown): boolean { + const status = + typeof (error as Partial)?.status === 'number' ? (error as Partial).status : null + + return status !== null && SPLITTABLE_GET_STATUS_CODES.has(status) +} + +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(): TDefiLlamaFetchTuning { + if (config.defillamaApiKey.length > 0) { + return { + 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 { + 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) + const batchDebugSummary = buildBatchDebugSummary(coinBatch, uniqueTimestamps) + const requestDetails = requests.map((request) => ({ + variant: request.variant, + method: request.init.method ?? 'GET', + urlLength: request.url.length + })) + debugLog('defillama', 'fetching price batch', { + attempt: attempt + 1, + 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 ((request.variant === 'pro_get' || request.variant === 'free_get') && isSplittableGetError(error)) { + const splitBatch = splitCoinBatch(coinBatch) + + if (splitBatch !== null) { + debugError('defillama', 'splitting price batch after get request failed', error, { + attempt: attempt + 1, + 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('defillama', 'price batch request variant failed', error, { + attempt: attempt + 1, + 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('defillama', 'fetched price batch', { + attempt: attempt + 1, + 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('defillama', 'price batch failed', error, { + attempt: attempt + 1, + tokenCount: coinBatch.length, + timestampCount: uniqueTimestamps.length, + pricePointCount: requestedPricePoints, + ...batchDebugSummary, + useProApi: tuning.useProApi + }) + throw error + } + + debugError('defillama', 'retrying price batch', error, { + nextAttempt: attempt + 2, + 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[] +): Promise>> { + const tuning = getDefiLlamaFetchTuning() + const uncachedTimestampKeys = requests.reduce>((uncachedKeys, request) => { + const tokenKey = `${getChainPrefix(request.chainId)}:${request.address.toLowerCase()}` + request.uncachedTimestamps?.forEach((timestamp) => { + uncachedKeys.add(toTimestampCacheKey(tokenKey, timestamp)) + }) + return uncachedKeys + }, new Set()) + 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('defillama', 'starting historical price fetch', { + tokens: tokenKeys.length, + timestamps: requestedTimestamps.length, + pricePointCount: requestedPricePoints, + 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 cacheableLookups = coins + .map((coin) => { + const tokenKey = `${coin.chain}:${coin.address.toLowerCase()}` + return { + tokenKey, + timestamps: filterCacheableTimestamps(coin.timestamps, uncachedTimestampKeys, tokenKey) + } + }) + .filter((lookup) => lookup.timestamps.length > 0) + + const [cachedPrices, cachedPriceMisses] = await Promise.all([ + getCachedPricesForTokenTimestamps(cacheableLookups), + getCachedPriceMissesForTokenTimestamps(cacheableLookups) + ]) + const cachedPricePoints = countPricePoints(cachedPrices) + debugLog('defillama', 'loaded cached price points', { + cachedPoints: cachedPricePoints + }) + const missingByToken = coins.reduce>((missing, coin) => { + const tokenKey = `${coin.chain}:${coin.address.toLowerCase()}` + const cachedForToken = cachedPrices.get(tokenKey) + const cachedMissesForToken = cachedPriceMisses.get(tokenKey) + const missingTimestamps = coin.timestamps.filter((timestamp) => { + if (cachedForToken?.has(timestamp)) { + result.get(tokenKey)!.set(timestamp, cachedForToken.get(timestamp)!) + return false + } + + if (cachedMissesForToken?.has(timestamp)) { + return false + } + + return true + }) + + if (missingTimestamps.length > 0) { + missing.set(tokenKey, missingTimestamps) + } + + return missing + }, new Map()) + + if (missingByToken.size === 0) { + debugLog('defillama', 'historical prices fully satisfied by cache', { + tokens: tokenKeys.length, + timestamps: requestedTimestamps.length, + pricePointCount: requestedPricePoints + }) + return result + } + + const newPrices: CachedPrice[] = [] + const newPriceMisses: CachedPriceMiss[] = [] + const fetchStats = { successfulBatches: 0 } + const missingFetchGroups = splitMissingCoinsByCacheability(coins, missingByToken, uncachedTimestampKeys) + const fetchMissingPriceGroup = async (fetchGroup: TMissingPriceFetchGroup): Promise => { + const tokenRequests = buildTokenRequests(fetchGroup.coins, tuning.timestampBatchSize) + const batches = buildRequestBatches(tokenRequests, tuning) + const batchGroups = chunkItems(batches, tuning.parallelRequests) + const allMissingTimestamps = [...new Set(fetchGroup.coins.flatMap((coin) => coin.timestamps))].sort((a, b) => a - b) + const missingPricePoints = countRequestedPricePoints(fetchGroup.coins) + + debugLog('defillama', 'prepared price fetch batches', { + fetchGroup: fetchGroup.label, + cacheResults: fetchGroup.cacheResults, + tokensToFetch: fetchGroup.coins.length, + missingTokens: fetchGroup.coins.length, + uniqueTimestamps: allMissingTimestamps.length, + missingPricePoints, + tokenRequests: tokenRequests.length, + batches: batches.length, + batchGroups: batchGroups.length, + maxTokensPerBatch: tuning.maxTokensPerBatch, + maxPricePointsPerBatch: tuning.maxPricePointsPerBatch, + maxTimestampsPerTokenPerBatch: tuning.maxTimestampsPerTokenPerBatch, + maxRequestUrlLength: tuning.maxRequestUrlLength, + useProApi: tuning.useProApi + }) + + await batchGroups.reduce>(async (previousGroupPromise, batchGroup, groupIndex) => { + await previousGroupPromise + + const batchResults = await Promise.allSettled(batchGroup.map((batch) => fetchBatch(batch.coinBatch, tuning))) + + batchResults.forEach((batchResult, batchIndex) => { + const batch = batchGroup[batchIndex] + + if (batchResult.status === 'rejected') { + 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( + `[DefiLlama] Failed to fetch prices for ${batch.coinBatch.length} tokens and ${batchPricePoints} token-timestamp pairs:`, + batchResult.reason + ) + debugError('defillama', 'price batch group member failed', batchResult.reason, { + fetchGroup: fetchGroup.label, + tokenCount: batch.coinBatch.length, + timestampCount: batchTimestamps.length, + pricePointCount: batchPricePoints, + firstTimestamp: batchTimestamps[0] ?? null, + lastTimestamp: batchTimestamps.at(-1) ?? null + }) + return + } + + fetchStats.successfulBatches += 1 + + const materializedPrices = materializeRequestedPrices(batch.coinBatch, batchResult.value) + const materializedPriceMisses = materializeRequestedPriceMisses(batch.coinBatch, batchResult.value) + materializedPrices.forEach(({ tokenKey, timestamp, price }) => { + if (!result.has(tokenKey)) { + result.set(tokenKey, new Map()) + } + + const existingMap = result.get(tokenKey)! + existingMap.set(timestamp, price) + if (fetchGroup.cacheResults) { + newPrices.push({ tokenKey, timestamp, price }) + } + }) + + if (fetchGroup.cacheResults) { + newPriceMisses.push(...materializedPriceMisses) + } + }) + + if (groupIndex < batchGroups.length - 1 && tuning.interGroupDelayMs > 0) { + await wait(tuning.interGroupDelayMs) + } + }, Promise.resolve()) + } + + await missingFetchGroups.reduce>(async (previousGroupPromise, fetchGroup) => { + await previousGroupPromise + await fetchMissingPriceGroup(fetchGroup) + }, Promise.resolve()) + + if (fetchStats.successfulBatches === 0 && countPricePoints(result) === 0) { + throw new Error('Failed to fetch token prices from DefiLlama') + } + + if (newPrices.length > 0) { + saveCachedPrices(newPrices).catch(() => {}) + } + + if (newPriceMisses.length > 0) { + saveCachedPriceMisses(newPriceMisses).catch(() => {}) + } + + debugLog('defillama', 'completed historical price fetch', { + successfulBatches: fetchStats.successfulBatches, + totalPricePoints: countPricePoints(result), + newPrices: newPrices.length, + newPriceMisses: newPriceMisses.length + }) + + 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..3059d37eb --- /dev/null +++ b/api/lib/holdings/services/graphql.test.ts @@ -0,0 +1,480 @@ +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' } + }) +} + +async function importGraphqlModule() { + vi.resetModules() + return import('./graphql') +} + +describe('fetchRawUserPnlEvents', () => { + afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllGlobals() + }) + + it('enriches transaction events from address event tx hashes when tx-from queries are empty', async () => { + const txHashQueries: Array<{ queryName: string; chainId: number; transactionHashes: string[] }> = [] + 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 createGraphqlResponse({ + deposits: { aggregate: { count: 0 } }, + withdrawals: { aggregate: { count: 0 } }, + transfersIn: { aggregate: { count: 1 } }, + transfersOut: { aggregate: { count: 0 } }, + v2Deposits: { aggregate: { count: 0 } }, + v2Withdrawals: { aggregate: { count: 0 } } + }) + } + + if (query.includes('GetDepositsByTransactionHashes')) { + txHashQueries.push({ + queryName: 'Deposit', + chainId: Number(variables.chainId), + transactionHashes: [...((variables.transactionHashes as string[]) ?? [])] + }) + + return createGraphqlResponse({ + Deposit: [ + { + id: 'tx-hash-deposit', + vaultAddress: VAULT, + chainId: 1, + blockNumber: 2, + blockTimestamp: 200, + logIndex: 1, + transactionHash: TX_HASH, + transactionFrom: ROUTER, + owner: ROUTER, + sender: ROUTER, + assets: '1000', + shares: '900' + } + ] + }) + } + + if (query.includes('GetWithdrawalsByTransactionHashes')) { + txHashQueries.push({ + queryName: 'Withdraw', + chainId: Number(variables.chainId), + transactionHashes: [...((variables.transactionHashes as string[]) ?? [])] + }) + + return createGraphqlResponse({ Withdraw: [] }) + } + + if (query.includes('GetTransfersByTransactionHashes')) { + txHashQueries.push({ + queryName: 'Transfer', + chainId: Number(variables.chainId), + transactionHashes: [...((variables.transactionHashes as string[]) ?? [])] + }) + + return createGraphqlResponse({ Transfer: [] }) + } + + if (query.includes('GetV2DepositsByTransactionHashes')) { + return createGraphqlResponse({ V2Deposit: [] }) + } + + if (query.includes('GetV2WithdrawalsByTransactionHashes')) { + return createGraphqlResponse({ V2Withdraw: [] }) + } + + if (query.includes('GetTransfersIn')) { + return createGraphqlResponse({ + Transfer: [ + { + id: 'address-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(') || + query.includes('GetDepositsByTransactionFrom') || + query.includes('GetWithdrawalsByTransactionFrom') || + query.includes('GetV2DepositsByTransactionFrom') || + query.includes('GetV2WithdrawalsByTransactionFrom') || + query.includes('GetTransfersByTransactionFrom') + ) { + const resultKey = query.includes('V2Deposit') + ? 'V2Deposit' + : query.includes('V2Withdraw') + ? 'V2Withdraw' + : query.includes('Withdraw') + ? 'Withdraw' + : query.includes('Transfer') + ? 'Transfer' + : 'Deposit' + + return createGraphqlResponse({ [resultKey]: [] }) + } + + throw new Error(`Unexpected query: ${query}`) + }) + + vi.stubGlobal('fetch', fetchStub) + + const { fetchRawUserPnlEvents } = await importGraphqlModule() + const context = await fetchRawUserPnlEvents(USER, 'all', undefined, 'parallel') + + expect(context.addressEvents.transfersIn).toHaveLength(1) + expect(context.transactionEvents.deposits).toEqual([ + expect.objectContaining({ + id: 'tx-hash-deposit', + transactionHash: TX_HASH, + owner: ROUTER, + assets: '1000', + shares: '900' + }) + ]) + expect(context.transactionEvents.withdrawals).toEqual([]) + expect(txHashQueries).toEqual([ + { queryName: 'Deposit', chainId: 1, transactionHashes: [TX_HASH] }, + { queryName: 'Withdraw', chainId: 1, transactionHashes: [TX_HASH] }, + { queryName: 'Transfer', chainId: 1, transactionHashes: [TX_HASH] } + ]) + }) + + 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(') || + query.includes('GetDepositsByTransactionFrom') || + query.includes('GetWithdrawalsByTransactionFrom') || + query.includes('GetV2DepositsByTransactionFrom') || + query.includes('GetV2WithdrawalsByTransactionFrom') || + query.includes('GetTransfersByTransactionFrom') || + query.includes('GetDepositsByTransactionHashes') || + query.includes('GetWithdrawalsByTransactionHashes') || + query.includes('GetTransfersByTransactionHashes') || + query.includes('GetV2DepositsByTransactionHashes') || + query.includes('GetV2WithdrawalsByTransactionHashes') + ) { + const resultKey = query.includes('V2Deposit') + ? 'V2Deposit' + : query.includes('V2Withdraw') + ? 'V2Withdraw' + : query.includes('Withdraw') + ? 'Withdraw' + : query.includes('Transfer') + ? 'Transfer' + : 'Deposit' + + return createGraphqlResponse({ [resultKey]: [] }) + } + + throw new Error(`Unexpected query: ${query}`) + }) + + vi.stubGlobal('fetch', fetchStub) + + const { fetchRawUserPnlEvents } = await importGraphqlModule() + const context = await fetchRawUserPnlEvents(USER, 'all', undefined, 'parallel') + + expect(context.addressEvents.transfersIn).toHaveLength(1001) + expect(context.addressEvents.transfersIn[0]).toEqual( + expect.objectContaining({ + id: 'aggregate-transfer-in-0' + }) + ) + expect(context.addressEvents.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(') || + query.includes('GetDepositsByTransactionFrom') || + query.includes('GetWithdrawalsByTransactionFrom') || + query.includes('GetV2DepositsByTransactionFrom') || + query.includes('GetV2WithdrawalsByTransactionFrom') || + query.includes('GetTransfersByTransactionFrom') || + query.includes('GetDepositsByTransactionHashes') || + query.includes('GetWithdrawalsByTransactionHashes') || + query.includes('GetTransfersByTransactionHashes') || + query.includes('GetV2DepositsByTransactionHashes') || + query.includes('GetV2WithdrawalsByTransactionHashes') + ) { + const resultKey = query.includes('V2Deposit') + ? 'V2Deposit' + : query.includes('V2Withdraw') + ? 'V2Withdraw' + : query.includes('Withdraw') + ? 'Withdraw' + : query.includes('Transfer') + ? 'Transfer' + : 'Deposit' + + return createGraphqlResponse({ [resultKey]: [] }) + } + + throw new Error(`Unexpected query: ${query}`) + }) + + vi.stubGlobal('fetch', fetchStub) + + const { fetchRawUserPnlEvents } = await importGraphqlModule() + const context = await fetchRawUserPnlEvents(USER, 'all', undefined, 'parallel', 'all') + + expect(context.addressEvents.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 across history and pnl consumers', 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 + + 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(') || + query.includes('GetDepositsByTransactionFrom') || + query.includes('GetWithdrawalsByTransactionFrom') || + query.includes('GetV2DepositsByTransactionFrom') || + query.includes('GetV2WithdrawalsByTransactionFrom') || + query.includes('GetTransfersByTransactionFrom') || + query.includes('GetDepositsByTransactionHashes') || + query.includes('GetWithdrawalsByTransactionHashes') || + query.includes('GetTransfersByTransactionHashes') || + query.includes('GetV2DepositsByTransactionHashes') || + query.includes('GetV2WithdrawalsByTransactionHashes') + ) { + const resultKey = query.includes('V2Deposit') + ? 'V2Deposit' + : query.includes('V2Withdraw') + ? 'V2Withdraw' + : query.includes('Withdraw') + ? 'Withdraw' + : query.includes('Transfer') + ? 'Transfer' + : 'Deposit' + + return createGraphqlResponse({ [resultKey]: [] }) + } + + throw new Error(`Unexpected query: ${query}`) + }) + + vi.stubGlobal('fetch', fetchStub) + + const { fetchRawUserPnlEvents, fetchUserEvents } = await importGraphqlModule() + const [historyEvents, pnlContext] = await Promise.all([ + fetchUserEvents(USER, 'all', 123456), + fetchRawUserPnlEvents(USER, 'all', 123456) + ]) + + expect(historyEvents.transfersIn).toEqual([ + expect.objectContaining({ + id: 'shared-transfer-in' + }) + ]) + expect(pnlContext.addressEvents.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) + expect( + fetchStub.mock.calls.filter(([, init]) => + String((init as RequestInit | undefined)?.body ?? '').includes('GetTransfersByTransactionFrom') + ) + ).toHaveLength(1) + }) +}) diff --git a/api/lib/holdings/services/graphql.ts b/api/lib/holdings/services/graphql.ts new file mode 100644 index 000000000..d9ed90b17 --- /dev/null +++ b/api/lib/holdings/services/graphql.ts @@ -0,0 +1,1571 @@ +import { getAddress } from 'viem' +import { config } 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_FROM_QUERY = ` + query GetDepositsByTransactionFrom($transactionFrom: String!, $limit: Int!, $offset: Int!, $maxTimestamp: Int) { + Deposit(where: { transactionFrom: { _eq: $transactionFrom }, 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_FROM_QUERY = ` + query GetWithdrawalsByTransactionFrom($transactionFrom: String!, $limit: Int!, $offset: Int!, $maxTimestamp: Int) { + Withdraw(where: { transactionFrom: { _eq: $transactionFrom }, 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_FROM_QUERY = ` + query GetV2DepositsByTransactionFrom($transactionFrom: String!, $limit: Int!, $offset: Int!, $maxTimestamp: Int) { + V2Deposit(where: { transactionFrom: { _eq: $transactionFrom }, 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_FROM_QUERY = ` + query GetV2WithdrawalsByTransactionFrom($transactionFrom: String!, $limit: Int!, $offset: Int!, $maxTimestamp: Int) { + V2Withdraw(where: { transactionFrom: { _eq: $transactionFrom }, 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_FROM_QUERY = ` + query GetTransfersByTransactionFrom($transactionFrom: String!, $limit: Int!, $offset: Int!, $maxTimestamp: Int) { + Transfer(where: { transactionFrom: { _eq: $transactionFrom }, 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 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 = config.envioPassword + if (password && password !== 'testing') { + headers['x-hasura-admin-secret'] = password + } + + const response = await fetch(config.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() + ) +} + +function collectAddressTransactionHashes(events: UserEvents): Map { + const groupedHashes = new Map>() + const allAddressEvents = [...events.deposits, ...events.withdrawals, ...events.transfersIn, ...events.transfersOut] + + allAddressEvents.forEach((event) => { + if (!event.transactionHash) { + return + } + + const txHashes = groupedHashes.get(event.chainId) ?? new Set() + txHashes.add(event.transactionHash.toLowerCase()) + groupedHashes.set(event.chainId, txHashes) + }) + + return Array.from(groupedHashes.entries()).reduce>((grouped, [chainId, hashes]) => { + grouped.set(chainId, Array.from(hashes.values())) + return grouped + }, new Map()) +} + +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[] +] + +type TransactionFromEventResults = [ + DepositEvent[], + WithdrawEvent[], + V2DepositEvent[], + V2WithdrawEvent[], + TransferEvent[] +] + +const inFlightAddressScopedEventFetches = new Map>() + +export interface RawPnlEventContext { + addressEvents: UserEvents + transactionEvents: { + deposits: DepositEvent[] + withdrawals: WithdrawEvent[] + transfers: TransferEvent[] + } +} + +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 +} + +function getTransactionFromEventFetches( + addressLower: string, + maxTimestamp: number | undefined, + paginationMode: HoldingsEventPaginationMode +): Promise { + const fetchFn = paginationMode === 'all' ? fetchAllSingleQuery : fetchAllSequential + + return Promise.all([ + fetchFn(DEPOSITS_BY_TX_FROM_QUERY, 'transactionFrom', addressLower, 'Deposit', maxTimestamp), + fetchFn(WITHDRAWALS_BY_TX_FROM_QUERY, 'transactionFrom', addressLower, 'Withdraw', maxTimestamp), + fetchFn(V2_DEPOSITS_BY_TX_FROM_QUERY, 'transactionFrom', addressLower, 'V2Deposit', maxTimestamp), + fetchFn( + V2_WITHDRAWALS_BY_TX_FROM_QUERY, + 'transactionFrom', + addressLower, + 'V2Withdraw', + maxTimestamp + ), + fetchFn(TRANSFERS_BY_TX_FROM_QUERY, 'transactionFrom', addressLower, 'Transfer', maxTimestamp) + ]) as Promise +} + +// 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 +): Promise { + const address = getGraphqlAddress(userAddress) + const addressLower = address.toLowerCase() + const boundedLimit = Math.max(1, limitPerSource) + + const [v3Deposits, v3Withdrawals, v2DepositsRaw, v2WithdrawalsRaw, transfersIn, transfersOut] = await Promise.all([ + fetchRecentLimited(RECENT_DEPOSITS_QUERY, 'owner', address, 'Deposit', boundedLimit, 0, maxTimestamp), + fetchRecentLimited( + RECENT_WITHDRAWALS_QUERY, + 'owner', + address, + 'Withdraw', + boundedLimit, + 0, + maxTimestamp + ), + fetchRecentLimited( + RECENT_V2_DEPOSITS_QUERY, + 'recipient', + address, + 'V2Deposit', + boundedLimit, + 0, + maxTimestamp + ), + fetchRecentLimited( + RECENT_V2_WITHDRAWALS_QUERY, + 'recipient', + address, + 'V2Withdraw', + boundedLimit, + 0, + maxTimestamp + ), + fetchRecentLimited( + RECENT_TRANSFERS_IN_QUERY, + 'receiver', + address, + 'Transfer', + boundedLimit, + 0, + maxTimestamp + ), + fetchRecentLimited( + RECENT_TRANSFERS_OUT_QUERY, + 'sender', + address, + 'Transfer', + boundedLimit, + 0, + 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, + 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)) + } +} + +export async function fetchRawUserPnlEvents( + userAddress: string, + version: VaultVersion = 'all', + maxTimestamp?: number, + fetchType: HoldingsEventFetchType = 'seq', + paginationMode: HoldingsEventPaginationMode = 'paged' +): Promise { + const address = getGraphqlAddress(userAddress) + const addressLower = address.toLowerCase() + const addressEventsPromise = fetchAddressScopedEvents(address, maxTimestamp, fetchType, paginationMode) + const transactionFromEventsPromise = getTransactionFromEventFetches(address, maxTimestamp, paginationMode) + + const [ + [ + addressV3Deposits, + addressV3Withdrawals, + addressV2DepositsRaw, + addressV2WithdrawalsRaw, + addressTransfersIn, + addressTransfersOut + ], + [txV3Deposits, txV3Withdrawals, txV2DepositsRaw, txV2WithdrawalsRaw, txTransfers] + ] = await Promise.all([addressEventsPromise, transactionFromEventsPromise]) + + const addressEvents = { + deposits: getDepositsByVersion(addressV3Deposits, addressV2DepositsRaw, version), + withdrawals: getWithdrawalsByVersion(addressV3Withdrawals, addressV2WithdrawalsRaw, version), + transfersIn: sortByBlock(addressTransfersIn), + transfersOut: sortByBlock(addressTransfersOut) + } + const addressTransactionHashes = collectAddressTransactionHashes(addressEvents) + const [txHashV3Deposits, txHashV3Withdrawals, txHashV2DepositsRaw, txHashV2WithdrawalsRaw, txHashTransfers] = + await Promise.all([ + fetchAllByTransactionHashes( + DEPOSITS_BY_TX_HASHES_QUERY, + addressTransactionHashes, + 'Deposit', + maxTimestamp + ), + fetchAllByTransactionHashes( + WITHDRAWALS_BY_TX_HASHES_QUERY, + addressTransactionHashes, + 'Withdraw', + maxTimestamp + ), + fetchAllByTransactionHashes( + V2_DEPOSITS_BY_TX_HASHES_QUERY, + addressTransactionHashes, + 'V2Deposit', + maxTimestamp + ), + fetchAllByTransactionHashes( + V2_WITHDRAWALS_BY_TX_HASHES_QUERY, + addressTransactionHashes, + 'V2Withdraw', + maxTimestamp + ), + fetchAllByTransactionHashes( + TRANSFERS_BY_TX_HASHES_QUERY, + addressTransactionHashes, + 'Transfer', + maxTimestamp + ) + ]) + + const context = { + addressEvents, + transactionEvents: { + deposits: sortByBlock( + dedupeById([ + ...getDepositsByVersion(txV3Deposits, txV2DepositsRaw, version), + ...getDepositsByVersion(txHashV3Deposits, txHashV2DepositsRaw, version) + ]) + ), + withdrawals: sortByBlock( + dedupeById([ + ...getWithdrawalsByVersion(txV3Withdrawals, txV2WithdrawalsRaw, version), + ...getWithdrawalsByVersion(txHashV3Withdrawals, txHashV2WithdrawalsRaw, version) + ]) + ), + transfers: sortByBlock(dedupeById([...txTransfers, ...txHashTransfers])) + } + } + debugLog('graphql', 'fetched raw user pnl events', { + address: addressLower, + version, + addressDeposits: context.addressEvents.deposits.length, + addressWithdrawals: context.addressEvents.withdrawals.length, + addressTransfersIn: context.addressEvents.transfersIn.length, + addressTransfersOut: context.addressEvents.transfersOut.length, + txDeposits: context.transactionEvents.deposits.length, + txWithdrawals: context.transactionEvents.withdrawals.length, + txTransfers: context.transactionEvents.transfers.length, + maxTimestamp: maxTimestamp ?? null + }) + return context +} + +// 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..36a62a7f4 --- /dev/null +++ b/api/lib/holdings/services/kong.ts @@ -0,0 +1,221 @@ +import { config } 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 = `${config.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..1ef6d69dd --- /dev/null +++ b/api/lib/holdings/services/nestedVaultPrices.test.ts @@ -0,0 +1,141 @@ +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 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 + } + ] +]) + +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], + uncachedTimestamps: [200] + } + ], + metadata + ) + + expect(requests).toEqual([ + { + chainId: 1, + address: INNER_VAULT, + timestamps: [100, 200], + uncachedTimestamps: [200] + }, + { + chainId: 1, + address: UNDERLYING, + timestamps: [100, 200], + uncachedTimestamps: [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('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('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..6336ddbe5 --- /dev/null +++ b/api/lib/holdings/services/nestedVaultPrices.ts @@ -0,0 +1,172 @@ +import type { VaultMetadata } from '../types' +import { getChainPrefix, getPriceAtTimestamp, type THistoricalPriceRequest } from './defillama' +import { getPPS, type PPSTimeline } from './kong' +import { toVaultKey } from './pnlShared' + +type TPriceRequestDraft = { + chainId: number + address: string + timestamps: Set + uncachedTimestamps: Set + includeUncachedTimestamps: boolean +} + +type TVaultIdentifier = { + chainId: number + vaultAddress: string +} + +function priceMapKey(chainId: number, tokenAddress: string): string { + return `${getChainPrefix(chainId)}:${tokenAddress.toLowerCase()}` +} + +function priceRequestKey(chainId: number, tokenAddress: string): string { + return `${chainId}:${tokenAddress.toLowerCase()}` +} + +function addPriceRequest( + drafts: Map, + request: THistoricalPriceRequest, + includeUncachedTimestamps: boolean +): void { + const key = priceRequestKey(request.chainId, request.address) + const draft = drafts.get(key) ?? { + chainId: request.chainId, + address: request.address.toLowerCase(), + timestamps: new Set(), + uncachedTimestamps: new Set(), + includeUncachedTimestamps + } + + request.timestamps.forEach((timestamp) => { + draft.timestamps.add(timestamp) + }) + request.uncachedTimestamps?.forEach((timestamp) => { + draft.timestamps.add(timestamp) + draft.uncachedTimestamps.add(timestamp) + }) + draft.includeUncachedTimestamps ||= includeUncachedTimestamps + drafts.set(key, draft) +} + +function materializePriceRequests(drafts: Map): THistoricalPriceRequest[] { + return Array.from(drafts.values()).map((draft) => { + const uncachedTimestamps = Array.from(draft.uncachedTimestamps).sort((a, b) => a - b) + + return { + chainId: draft.chainId, + address: draft.address, + timestamps: Array.from(draft.timestamps).sort((a, b) => a - b), + ...(draft.includeUncachedTimestamps || uncachedTimestamps.length > 0 ? { uncachedTimestamps } : {}) + } + }) +} + +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 function getNestedVaultPpsIdentifiersFromPriceRequests( + requests: THistoricalPriceRequest[], + vaultMetadata: Map +): TVaultIdentifier[] { + return mergeVaultIdentifiers( + requests.flatMap((request) => { + const nestedVault = vaultMetadata.get(toVaultKey(request.chainId, request.address)) + + return nestedVault ? [{ chainId: nestedVault.chainId, vaultAddress: nestedVault.address }] : [] + }) + ) +} + +export function expandNestedVaultAssetPriceRequests( + requests: THistoricalPriceRequest[], + vaultMetadata: Map +): THistoricalPriceRequest[] { + const drafts = new Map() + + requests.forEach((request) => { + const includeUncachedTimestamps = request.uncachedTimestamps !== undefined + addPriceRequest(drafts, request, includeUncachedTimestamps) + + const nestedVault = vaultMetadata.get(toVaultKey(request.chainId, request.address)) + if (!nestedVault) { + return + } + + addPriceRequest( + drafts, + { + chainId: nestedVault.chainId, + address: nestedVault.token.address, + timestamps: request.timestamps, + uncachedTimestamps: request.uncachedTimestamps + }, + includeUncachedTimestamps + ) + }) + + return materializePriceRequests(drafts) +} + +export function deriveNestedVaultAssetPriceData(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 +} diff --git a/api/lib/holdings/services/pnl.modes.test.ts b/api/lib/holdings/services/pnl.modes.test.ts new file mode 100644 index 000000000..c97ed5531 --- /dev/null +++ b/api/lib/holdings/services/pnl.modes.test.ts @@ -0,0 +1,792 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { RawPnlEventContext } from './graphql' +import type { HoldingsPnLDrilldownResponse, HoldingsPnLResponse } from './pnl' + +const VAULT = '0xbe53a109b494e5c9f97b9cd39fe969be68bf6204' +const REWARD_DISTRIBUTOR = '0xb226c52eb411326cdb54824a88abafdaaff16d3d' +const REWARD_VAULT = '0xbf319ddc2edc1eb6fdf9910e39b37be221c8805f' +const USER = '0x93a62da5a14c80f265dabc077fcee437b1a0efde' +const ROUTER = '0x1111111111111111111111111111111111111111' +const PRICE_KEY = 'ethereum:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48' + +const fetchRawUserPnlEventsMock = vi.fn<() => Promise>() +const fetchMultipleVaultsMetadataMock = vi.fn() +const fetchMultipleVaultsPPSMock = vi.fn() +const fetchHistoricalPricesMock = vi.fn() + +vi.mock('./graphql', async () => { + const actual = await vi.importActual('./graphql') + return { + ...actual, + fetchRawUserPnlEvents: fetchRawUserPnlEventsMock + } +}) + +vi.mock('./vaults', async () => { + const actual = await vi.importActual('./vaults') + return { + ...actual, + fetchMultipleVaultsMetadata: fetchMultipleVaultsMetadataMock + } +}) + +vi.mock('./kong', async () => { + const actual = await vi.importActual('./kong') + return { + ...actual, + fetchMultipleVaultsPPS: fetchMultipleVaultsPPSMock + } +}) + +vi.mock('./defillama', async () => { + const actual = await vi.importActual('./defillama') + return { + ...actual, + fetchHistoricalPrices: fetchHistoricalPricesMock + } +}) + +function createTransferInContext(): RawPnlEventContext { + return { + addressEvents: { + deposits: [ + { + id: 'anchor-deposit', + vaultAddress: VAULT, + chainId: 1, + blockNumber: 3, + blockTimestamp: 300, + logIndex: 1, + transactionHash: '0xanchor-deposit', + transactionFrom: USER, + owner: USER, + sender: USER, + assets: '1100000', + shares: '1000000' + } + ], + withdrawals: [], + transfersIn: [ + { + id: 'unknown-transfer-in', + vaultAddress: VAULT, + chainId: 1, + blockNumber: 1, + blockTimestamp: 100, + logIndex: 1, + transactionHash: '0xtransfer-in', + transactionFrom: ROUTER, + sender: ROUTER, + receiver: USER, + value: '100000000' + } + ], + transfersOut: [] + }, + transactionEvents: { + deposits: [], + withdrawals: [], + transfers: [] + } + } +} + +function createUnknownWithdrawalContext(): RawPnlEventContext { + return { + addressEvents: { + deposits: [], + withdrawals: [ + { + id: 'unknown-withdrawal', + vaultAddress: VAULT, + chainId: 1, + blockNumber: 2, + blockTimestamp: 200, + logIndex: 1, + transactionHash: '0xwithdraw', + transactionFrom: USER, + owner: USER, + assets: '120000000', + shares: '100000000' + } + ], + transfersIn: createTransferInContext().addressEvents.transfersIn, + transfersOut: [] + }, + transactionEvents: { + deposits: [], + withdrawals: [], + transfers: [] + } + } +} + +function createTransferOnlyContext(): RawPnlEventContext { + return { + addressEvents: { + deposits: [], + withdrawals: [], + transfersIn: createTransferInContext().addressEvents.transfersIn, + transfersOut: [] + }, + transactionEvents: { + deposits: [], + withdrawals: [], + transfers: [] + } + } +} + +function createRewardTransferInContext(): RawPnlEventContext { + const rewardTransferIn = { + id: 'reward-transfer-in', + vaultAddress: REWARD_VAULT, + chainId: 1, + blockNumber: 1, + blockTimestamp: 100, + logIndex: 1, + transactionHash: '0xreward-transfer-in', + transactionFrom: REWARD_DISTRIBUTOR, + sender: REWARD_DISTRIBUTOR, + receiver: USER, + value: '100000000' + } + + return { + addressEvents: { + deposits: [], + withdrawals: [], + transfersIn: [rewardTransferIn], + transfersOut: [] + }, + transactionEvents: { + deposits: [], + withdrawals: [], + transfers: [rewardTransferIn] + } + } +} + +function createTransferOutContext(): RawPnlEventContext { + return { + addressEvents: { + deposits: createTransferInContext().addressEvents.deposits, + withdrawals: [], + transfersIn: createTransferInContext().addressEvents.transfersIn, + transfersOut: [ + { + id: 'unknown-transfer-out', + vaultAddress: VAULT, + chainId: 1, + blockNumber: 2, + blockTimestamp: 200, + logIndex: 1, + transactionHash: '0xtransfer-out', + transactionFrom: USER, + sender: USER, + receiver: ROUTER, + value: '100000000' + } + ] + }, + transactionEvents: { + deposits: [], + withdrawals: [], + transfers: [] + } + } +} + +function configureServiceMocks( + rawContext: RawPnlEventContext, + overrides?: { + vaultAddress?: string + metadata?: { + tokenAddress: string + symbol: string + decimals: number + shareDecimals?: number + category?: 'stable' | 'volatile' + isHidden?: boolean + } + ppsTimeline?: Map + priceKey?: string + prices?: Map + } +): void { + const vaultAddress = overrides?.vaultAddress ?? VAULT + const metadata = overrides?.metadata ?? { + tokenAddress: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + symbol: 'USDC', + decimals: 6, + shareDecimals: 6, + category: 'stable' as const + } + const ppsTimeline = + overrides?.ppsTimeline ?? + new Map([ + [100, 1], + [200, 1.2], + [300, 1.1] + ]) + const priceKey = overrides?.priceKey ?? PRICE_KEY + const prices = + overrides?.prices ?? + new Map([ + [100, 2], + [200, 3], + [300, 3] + ]) + + fetchRawUserPnlEventsMock.mockResolvedValue(rawContext) + fetchMultipleVaultsMetadataMock.mockResolvedValue( + new Map([ + [ + `1:${vaultAddress}`, + { + address: vaultAddress, + chainId: 1, + version: 'v3', + isHidden: metadata.isHidden ?? false, + token: { + address: metadata.tokenAddress, + symbol: metadata.symbol, + decimals: metadata.decimals + }, + category: metadata.category ?? 'volatile', + decimals: metadata.shareDecimals ?? metadata.decimals + } + ] + ]) + ) + fetchMultipleVaultsPPSMock.mockResolvedValue(new Map([[`1:${vaultAddress}`, ppsTimeline]])) + fetchHistoricalPricesMock.mockResolvedValue(new Map([[priceKey, prices]])) +} + +async function importPnlModule() { + vi.resetModules() + return import('./pnl') +} + +async function getSingleVaultResponse( + rawContext: RawPnlEventContext, + unknownMode?: 'strict' | 'zero_basis' | 'windfall', + overrides?: Parameters[1] +): Promise { + configureServiceMocks(rawContext, overrides) + const { getHoldingsPnL } = await importPnlModule() + const response = await getHoldingsPnL(USER, 'all', unknownMode) + expect(response.vaults).toHaveLength(1) + return response +} + +async function getSingleVaultDrilldownResponse( + rawContext: RawPnlEventContext, + unknownMode?: 'strict' | 'zero_basis' | 'windfall', + overrides?: Parameters[1] +): Promise { + configureServiceMocks(rawContext, overrides) + const { getHoldingsPnLDrilldown } = await importPnlModule() + const response = await getHoldingsPnLDrilldown(USER, 'all', unknownMode) + expect(response.vaults).toHaveLength(1) + return response +} + +describe('getHoldingsPnL unknown transfer-in modes', () => { + afterEach(() => { + vi.restoreAllMocks() + vi.clearAllMocks() + vi.unstubAllGlobals() + }) + + it('defaults to windfall and splits free transfer-ins into windfall plus market unrealized pnl', async () => { + vi.spyOn(Date, 'now').mockReturnValue(300_000) + + const response = await getSingleVaultResponse(createTransferInContext()) + const vault = response.vaults[0] + + expect(response.unknownTransferInPnlMode).toBe('windfall') + expect(response.summary.totalUnknownCostBasisValueUsd).toBe(0) + expect(response.summary.totalWindfallPnlUsd).toBeCloseTo(200) + expect(response.summary.totalUnrealizedPnlUsd).toBeCloseTo(130) + expect(response.summary.totalPnlUsd).toBeCloseTo(130) + expect(response.summary.totalEconomicGainUsd).toBeCloseTo(330) + expect(vault.windfallPnlUsd).toBeCloseTo(200) + expect(vault.unrealizedPnlUsd).toBeCloseTo(130) + expect(vault.totalPnlUsd).toBeCloseTo(130) + expect(vault.totalEconomicGainUsd).toBeCloseTo(330) + }) + + it('uses paged sequential event fetching by default and allows explicit overrides', async () => { + vi.spyOn(Date, 'now').mockReturnValue(300_000) + configureServiceMocks(createTransferInContext()) + + const { getHoldingsPnL } = await importPnlModule() + + await getHoldingsPnL(USER) + expect(fetchRawUserPnlEventsMock).toHaveBeenLastCalledWith(USER, 'all', undefined, 'seq', 'paged') + + await getHoldingsPnL(USER, 'all', 'windfall', 'parallel', 'all') + expect(fetchRawUserPnlEventsMock).toHaveBeenLastCalledWith(USER, 'all', undefined, 'parallel', 'all') + }) + + it('filters versioned pnl responses using authoritative vault metadata', async () => { + vi.spyOn(Date, 'now').mockReturnValue(300_000) + configureServiceMocks(createTransferInContext()) + + const { getHoldingsPnL } = await importPnlModule() + + const v2Response = await getHoldingsPnL(USER, 'v2') + expect(v2Response.summary.totalVaults).toBe(0) + expect(v2Response.vaults).toEqual([]) + expect(fetchRawUserPnlEventsMock).toHaveBeenLastCalledWith(USER, 'all', undefined, 'seq', 'paged') + + const v3Response = await getHoldingsPnL(USER, 'v3') + expect(v3Response.summary.totalVaults).toBe(1) + expect(v3Response.vaults[0]?.vaultAddress).toBe(VAULT) + expect(fetchRawUserPnlEventsMock).toHaveBeenLastCalledWith(USER, 'all', undefined, 'seq', 'paged') + }) + + it('excludes hidden vaults from pnl responses', async () => { + vi.spyOn(Date, 'now').mockReturnValue(300_000) + configureServiceMocks(createTransferInContext(), { + metadata: { + tokenAddress: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + symbol: 'USDC', + decimals: 6, + shareDecimals: 6, + category: 'stable', + isHidden: true + } + }) + + const { getHoldingsPnL } = await importPnlModule() + const response = await getHoldingsPnL(USER) + + expect(response.summary.totalVaults).toBe(0) + expect(response.vaults).toEqual([]) + expect(fetchMultipleVaultsPPSMock).not.toHaveBeenCalled() + expect(fetchHistoricalPricesMock).not.toHaveBeenCalled() + }) + + it('treats unknown transfer-ins as full unrealized pnl in zero-basis mode', async () => { + vi.spyOn(Date, 'now').mockReturnValue(300_000) + + const response = await getSingleVaultResponse(createTransferInContext(), 'zero_basis') + const vault = response.vaults[0] + + expect(response.summary.totalUnknownCostBasisValueUsd).toBe(0) + expect(response.summary.totalWindfallPnlUsd).toBe(0) + expect(response.summary.totalUnrealizedPnlUsd).toBeCloseTo(330) + expect(response.summary.totalPnlUsd).toBeCloseTo(330) + expect(response.summary.totalEconomicGainUsd).toBeCloseTo(330) + expect(vault.windfallPnlUsd).toBe(0) + expect(vault.unrealizedPnlUsd).toBeCloseTo(330) + expect(vault.totalPnlUsd).toBeCloseTo(330) + expect(vault.totalEconomicGainUsd).toBeCloseTo(330) + }) + + it('treats recognized reward transfer-ins as complete zero-basis lots in windfall mode', async () => { + vi.spyOn(Date, 'now').mockReturnValue(300_000) + + const response = await getSingleVaultDrilldownResponse(createRewardTransferInContext(), 'windfall', { + vaultAddress: REWARD_VAULT + }) + const vault = response.vaults[0] + + expect(vault.costBasisStatus).toBe('complete') + expect(vault.windfallPnlUsd).toBe(0) + expect(vault.unrealizedPnlUsd).toBeCloseTo(330) + expect(vault.totalPnlUsd).toBeCloseTo(330) + expect(vault.totalEconomicGainUsd).toBeCloseTo(330) + expect(vault.eventCounts.rewardTransfersIn).toBe(1) + expect(vault.eventCounts.externalTransfersIn).toBe(0) + expect(vault.rewardTransferInEntries).toHaveLength(1) + expect(vault.rewardTransferInEntries[0]).toMatchObject({ + distributor: REWARD_DISTRIBUTOR, + location: 'vault', + sharesFormatted: 100, + receiptValueUsd: 200 + }) + expect(vault.unknownTransferInEntries).toEqual([]) + expect(vault.currentLots.vault).toHaveLength(1) + expect(vault.currentLots.vault[0]?.costBasis).toBe('0') + expect(vault.currentLots.vault[0]?.costBasisUsd).toBe(0) + expect(vault.currentLots.vault[0]?.currentValueUsd).toBeCloseTo(330) + expect(vault.journal).toHaveLength(1) + expect(vault.journal[0]).toMatchObject({ + txHash: '0xreward-transfer-in', + view: 'reward_in_vault', + rewardInVaultShares: '100000000', + unknownInVaultShares: '0' + }) + }) + + it('preserves strict-mode unknown cost basis behavior', async () => { + vi.spyOn(Date, 'now').mockReturnValue(300_000) + + const response = await getSingleVaultResponse(createTransferInContext(), 'strict') + const vault = response.vaults[0] + + expect(response.summary.totalUnknownCostBasisValueUsd).toBeCloseTo(330) + expect(response.summary.totalWindfallPnlUsd).toBe(0) + expect(response.summary.totalUnrealizedPnlUsd).toBe(0) + expect(response.summary.totalPnlUsd).toBe(0) + expect(response.summary.totalEconomicGainUsd).toBe(0) + expect(vault.unknownCostBasisValueUsd).toBeCloseTo(330) + expect(vault.totalPnlUsd).toBe(0) + expect(vault.totalEconomicGainUsd).toBe(0) + }) + + it('values transfer-only current holdings without fetching historical receipt prices', async () => { + vi.spyOn(Date, 'now').mockReturnValue(300_000) + + const windfallResponse = await getSingleVaultResponse(createTransferOnlyContext(), 'windfall') + const zeroBasisResponse = await getSingleVaultResponse(createTransferOnlyContext(), 'zero_basis') + const strictResponse = await getSingleVaultResponse(createTransferOnlyContext(), 'strict') + + expect(fetchHistoricalPricesMock).toHaveBeenNthCalledWith( + 1, + [{ chainId: 1, address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48' }], + [300] + ) + expect(fetchHistoricalPricesMock).toHaveBeenNthCalledWith( + 2, + [{ chainId: 1, address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48' }], + [300] + ) + expect(fetchHistoricalPricesMock).toHaveBeenNthCalledWith( + 3, + [{ chainId: 1, address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48' }], + [300] + ) + + expect(windfallResponse.summary.totalCurrentValueUsd).toBeCloseTo(330) + expect(windfallResponse.summary.totalWindfallPnlUsd).toBeCloseTo(330) + expect(windfallResponse.summary.totalPnlUsd).toBe(0) + expect(windfallResponse.summary.totalEconomicGainUsd).toBeCloseTo(330) + + expect(zeroBasisResponse.summary.totalCurrentValueUsd).toBeCloseTo(330) + expect(zeroBasisResponse.summary.totalUnrealizedPnlUsd).toBeCloseTo(330) + expect(zeroBasisResponse.summary.totalPnlUsd).toBeCloseTo(330) + expect(zeroBasisResponse.summary.totalEconomicGainUsd).toBeCloseTo(330) + + expect(strictResponse.summary.totalCurrentValueUsd).toBeCloseTo(330) + expect(strictResponse.summary.totalUnknownCostBasisValueUsd).toBeCloseTo(330) + expect(strictResponse.summary.totalPnlUsd).toBe(0) + expect(strictResponse.summary.totalEconomicGainUsd).toBe(0) + }) + + it('computes realized pnl for unknown withdrawals in zero-basis and windfall modes', async () => { + vi.spyOn(Date, 'now').mockReturnValue(300_000) + + const zeroBasisResponse = await getSingleVaultResponse(createUnknownWithdrawalContext(), 'zero_basis') + const windfallResponse = await getSingleVaultResponse(createUnknownWithdrawalContext(), 'windfall') + + expect(zeroBasisResponse.summary.totalRealizedPnlUsd).toBeCloseTo(360) + expect(zeroBasisResponse.summary.totalWindfallPnlUsd).toBe(0) + expect(zeroBasisResponse.summary.totalPnlUsd).toBeCloseTo(360) + expect(zeroBasisResponse.summary.totalEconomicGainUsd).toBeCloseTo(360) + + expect(windfallResponse.summary.totalWindfallPnlUsd).toBeCloseTo(200) + expect(windfallResponse.summary.totalRealizedPnlUsd).toBeCloseTo(160) + expect(windfallResponse.summary.totalPnlUsd).toBeCloseTo(160) + expect(windfallResponse.summary.totalEconomicGainUsd).toBeCloseTo(360) + }) + + it('does not keep windfall on unknown shares that later leave through external transfers', async () => { + vi.spyOn(Date, 'now').mockReturnValue(300_000) + + const zeroBasisResponse = await getSingleVaultResponse(createTransferOutContext(), 'zero_basis') + const windfallResponse = await getSingleVaultResponse(createTransferOutContext(), 'windfall') + + expect(zeroBasisResponse.summary.totalPnlUsd).toBe(0) + expect(zeroBasisResponse.summary.totalEconomicGainUsd).toBe(0) + expect(windfallResponse.summary.totalWindfallPnlUsd).toBe(0) + expect(windfallResponse.summary.totalPnlUsd).toBe(0) + expect(windfallResponse.summary.totalEconomicGainUsd).toBe(0) + }) + + it('counts underlying token price appreciation for known-basis deposits in usd pnl', async () => { + vi.spyOn(Date, 'now').mockReturnValue(300_000) + + const response = await getSingleVaultResponse( + { + addressEvents: { + deposits: [ + { + id: 'known-deposit', + vaultAddress: VAULT, + chainId: 1, + blockNumber: 1, + blockTimestamp: 100, + logIndex: 1, + transactionHash: '0xknown-deposit', + transactionFrom: USER, + owner: USER, + sender: USER, + assets: '1000000000000000000', + shares: '1000000000000000000' + } + ], + withdrawals: [], + transfersIn: [], + transfersOut: [] + }, + transactionEvents: { + deposits: [], + withdrawals: [], + transfers: [] + } + }, + 'windfall', + { + metadata: { + tokenAddress: '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2', + symbol: 'WETH', + decimals: 18, + category: 'volatile' + }, + ppsTimeline: new Map([ + [100, 1], + [300, 1] + ]), + priceKey: 'ethereum:0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2', + prices: new Map([ + [100, 2000], + [300, 3000] + ]) + } + ) + + const vault = response.vaults[0] + + expect(vault.unrealizedPnlUnderlying).toBeCloseTo(0) + expect(vault.unrealizedPnlUsd).toBeCloseTo(1000) + expect(vault.totalPnlUsd).toBeCloseTo(1000) + expect(vault.totalEconomicGainUsd).toBeCloseTo(1000) + expect(response.summary.totalUnrealizedPnlUsd).toBeCloseTo(1000) + }) + + it('adds explicit underlying and known-basis usd fields to vault rows', async () => { + vi.spyOn(Date, 'now').mockReturnValue(300_000) + + const response = await getSingleVaultResponse( + { + addressEvents: { + deposits: [ + { + id: 'known-deposit', + vaultAddress: VAULT, + chainId: 1, + blockNumber: 1, + blockTimestamp: 100, + logIndex: 1, + transactionHash: '0xknown-deposit', + transactionFrom: USER, + owner: USER, + sender: USER, + assets: '1000000000000000000', + shares: '1000000000000000000' + } + ], + withdrawals: [], + transfersIn: [], + transfersOut: [] + }, + transactionEvents: { + deposits: [], + withdrawals: [], + transfers: [] + } + }, + 'windfall', + { + metadata: { + tokenAddress: '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2', + symbol: 'WETH', + decimals: 18 + }, + ppsTimeline: new Map([ + [100, 1], + [300, 1] + ]), + priceKey: 'ethereum:0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2', + prices: new Map([ + [100, 2000], + [300, 3000] + ]) + } + ) + + expect(response.vaults[0]).toMatchObject({ + currentUnderlying: 1, + vaultUnderlying: 1, + stakedUnderlying: 0, + currentKnownUnderlying: 1, + currentUnknownUnderlying: 0, + knownCostBasisUnderlying: 1, + knownCostBasisUsd: 2000 + }) + expect(response.vaults[0]?.metadata).toMatchObject({ + symbol: 'WETH', + decimals: 18, + assetDecimals: 18 + }) + }) + + it('returns drilldown lots, unknown-basis receipts, and journal rows', async () => { + vi.spyOn(Date, 'now').mockReturnValue(300_000) + + const response = await getSingleVaultDrilldownResponse(createTransferInContext(), 'windfall') + const vault = response.vaults[0] + + expect(vault.currentLots.vault).toHaveLength(2) + expect(vault.currentLots.staked).toHaveLength(0) + expect(vault.unknownTransferInEntries).toHaveLength(1) + expect(vault.unknownTransferInEntries[0]).toMatchObject({ + location: 'vault', + sharesFormatted: 100, + receiptValueUsd: 200 + }) + expect(vault.realizedEntries).toHaveLength(0) + expect(vault.journal).toHaveLength(2) + expect(vault.journal[0]).toMatchObject({ + txHash: '0xtransfer-in', + vaultLotsAfter: { + totalShares: '100000000', + unknownShares: '100000000' + } + }) + }) + + it('splits summary totals by stable and volatile vault categories', async () => { + const { summarizePnlVaults } = await import('./pnlValuation') + + const summary = summarizePnlVaults([ + { + chainId: 1, + vaultAddress: '0x1111111111111111111111111111111111111111', + stakingVaultAddress: null, + status: 'ok', + costBasisStatus: 'complete', + unknownTransferInPnlMode: 'windfall', + shares: '1', + sharesFormatted: 1, + vaultShares: '1', + vaultSharesFormatted: 1, + stakedShares: '0', + stakedSharesFormatted: 0, + knownCostBasisShares: '1', + unknownCostBasisShares: '0', + pricePerShare: 1, + tokenPrice: 1, + currentUnderlying: 10, + vaultUnderlying: 10, + stakedUnderlying: 0, + currentKnownUnderlying: 10, + currentUnknownUnderlying: 0, + knownCostBasisUnderlying: 10, + knownCostBasisUsd: 10, + currentValueUsd: 10, + vaultValueUsd: 10, + stakedValueUsd: 0, + unknownCostBasisValueUsd: 0, + windfallPnlUsd: 0, + realizedPnlUnderlying: 0, + realizedPnlUsd: 0, + unrealizedPnlUnderlying: 0, + unrealizedPnlUsd: 12, + totalPnlUsd: 12, + totalEconomicGainUsd: 12, + totalDepositedUnderlying: 0, + totalWithdrawnUnderlying: 0, + eventCounts: { + underlyingDeposits: 0, + underlyingWithdrawals: 0, + stakes: 0, + unstakes: 0, + rewardTransfersIn: 0, + externalTransfersIn: 0, + externalTransfersOut: 0, + migrationsIn: 0, + migrationsOut: 0, + unknownCostBasisTransfersIn: 0, + withdrawalsWithUnknownCostBasis: 0 + }, + metadata: { + symbol: 'USDC', + decimals: 6, + assetDecimals: 6, + tokenAddress: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + category: 'stable' + } + }, + { + chainId: 1, + vaultAddress: '0x2222222222222222222222222222222222222222', + stakingVaultAddress: null, + status: 'ok', + costBasisStatus: 'complete', + unknownTransferInPnlMode: 'windfall', + shares: '1', + sharesFormatted: 1, + vaultShares: '1', + vaultSharesFormatted: 1, + stakedShares: '0', + stakedSharesFormatted: 0, + knownCostBasisShares: '1', + unknownCostBasisShares: '0', + pricePerShare: 1, + tokenPrice: 1, + currentUnderlying: 20, + vaultUnderlying: 20, + stakedUnderlying: 0, + currentKnownUnderlying: 20, + currentUnknownUnderlying: 0, + knownCostBasisUnderlying: 20, + knownCostBasisUsd: 17, + currentValueUsd: 20, + vaultValueUsd: 20, + stakedValueUsd: 0, + unknownCostBasisValueUsd: 0, + windfallPnlUsd: 3, + realizedPnlUnderlying: 0, + realizedPnlUsd: 0, + unrealizedPnlUnderlying: 0, + unrealizedPnlUsd: 5, + totalPnlUsd: 5, + totalEconomicGainUsd: 8, + totalDepositedUnderlying: 0, + totalWithdrawnUnderlying: 0, + eventCounts: { + underlyingDeposits: 0, + underlyingWithdrawals: 0, + stakes: 0, + unstakes: 0, + rewardTransfersIn: 0, + externalTransfersIn: 0, + externalTransfersOut: 0, + migrationsIn: 0, + migrationsOut: 0, + unknownCostBasisTransfersIn: 0, + withdrawalsWithUnknownCostBasis: 0 + }, + metadata: { + symbol: 'WETH', + decimals: 18, + assetDecimals: 18, + tokenAddress: '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2', + category: 'volatile' + } + } + ]) + + expect(summary.totalPnlUsd).toBe(17) + expect(summary.totalEconomicGainUsd).toBe(20) + expect(summary.byCategory.stable.totalPnlUsd).toBe(12) + expect(summary.byCategory.stable.totalEconomicGainUsd).toBe(12) + expect(summary.byCategory.volatile.totalPnlUsd).toBe(5) + expect(summary.byCategory.volatile.totalEconomicGainUsd).toBe(8) + }) +}) diff --git a/api/lib/holdings/services/pnl.test.ts b/api/lib/holdings/services/pnl.test.ts new file mode 100644 index 000000000..371d99a22 --- /dev/null +++ b/api/lib/holdings/services/pnl.test.ts @@ -0,0 +1,1149 @@ +import { describe, expect, it } from 'vitest' +import type { DepositEvent, TransferEvent, VaultMetadata, WithdrawEvent } from '../types' +import { + buildRawPnlEvents, + filterDirectInteractionLedgers, + filterRelevantHoldingsLedgers, + processRawPnlEvents +} from './pnl' + +const USER = '0x2222222222222222222222222222222222222222' +const ROUTER = '0x1111111111111111111111111111111111111111' +const MIGRATOR = '0x9327e2fdc57c7d70782f29ab46f6385afaf4503c' +const YEARN_4626_ROUTER = '0x1112dbcf805682e828606f74ab717abf4b4fd8de' +const ENSO_EXECUTOR = '0x4fe93ebc4ce6ae4f81601cc7ce7139023919e003' +const STAKING_VAULT = '0x622fa41799406b120f9a40da843d358b7b2cfee3' +const UNDERLYING_VAULT = '0xbe53a109b494e5c9f97b9cd39fe969be68bf6204' +const REWARD_DISTRIBUTOR = '0xb226c52eb411326cdb54824a88abafdaaff16d3d' +const REWARD_VAULT = '0xbf319ddc2edc1eb6fdf9910e39b37be221c8805f' +const KATANA_REWARD_DISTRIBUTOR = '0xa03e39cdeac8c2823a6edc80956207294807c20d' +const KATANA_REWARD_VAULT = '0x80c34bd3a3569e126e7055831036aa7b212cb159' +const KATANA_MULTISIG_REWARD_DISTRIBUTORS = [ + '0x67c912ff560951526bffdff66dfbd4df8ae23756', + '0x5480f3152748809495bd56c14eab4a622aa3a19b' +] as const +const KATANA_MULTISIG_REWARD_VAULTS = [ + '0x80c34bd3a3569e126e7055831036aa7b212cb159', + '0xe007ca01894c863d7898045ed5a3b4abf0b18f37', + '0xaa0362ecc584b985056e47812931270b99c91f9d', + '0x9a6bd7b6fd5c4f87eb66356441502fc7dcdd185b' +] as const +const SOURCE_MIGRATION_VAULT = '0x1635b506a88fbf428465ad65d00e8d6b6e5846c3' +const DESTINATION_MIGRATION_VAULT = '0x75a291f0232add37d72dd1dcff55b715755ecdee' +const ENDO_STEP_VAULT = '0xc56413869c6cdf96496f2b1ef801fedbdfa7ddb0' +const BOLD_TOKEN = '0x6440f144b7e50d6a8439336510312d2f54beb01d' +const YBOLD_VAULT = '0x9f4330700a36b29952869fac9b33f45eedd8a3d8' +const YSYBOLD_VAULT = '0x23346b04a7f55b8760e5860aa5a77383d63491cd' +const FAMILY_KEY = `1:${UNDERLYING_VAULT}` +const REWARD_FAMILY_KEY = `1:${REWARD_VAULT}` +const KATANA_REWARD_FAMILY_KEY = `747474:${KATANA_REWARD_VAULT}` +const YBOLD_FAMILY_KEY = `1:${YBOLD_VAULT}` +const YSYBOLD_FAMILY_KEY = `1:${YSYBOLD_VAULT}` +const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000' +const BOLD_STACK_METADATA = new Map([ + [ + YBOLD_FAMILY_KEY, + { + address: YBOLD_VAULT, + chainId: 1, + version: 'v3', + token: { + address: BOLD_TOKEN, + symbol: 'BOLD', + decimals: 18 + }, + decimals: 18 + } + ], + [ + YSYBOLD_FAMILY_KEY, + { + address: YSYBOLD_VAULT, + chainId: 1, + version: 'v3', + token: { + address: YBOLD_VAULT, + symbol: 'yBOLD', + decimals: 18 + }, + decimals: 18 + } + ] +]) + +function createDepositEvent(overrides: Partial): DepositEvent { + return { + id: 'deposit', + vaultAddress: UNDERLYING_VAULT, + chainId: 1, + blockNumber: 1, + blockTimestamp: 100, + logIndex: 1, + transactionHash: '0xdeposit', + transactionFrom: USER, + owner: USER, + sender: USER, + assets: '1000', + shares: '100', + ...overrides + } +} + +function createWithdrawEvent(overrides: Partial): WithdrawEvent { + return { + id: 'withdraw', + vaultAddress: UNDERLYING_VAULT, + chainId: 1, + blockNumber: 1, + blockTimestamp: 100, + logIndex: 1, + transactionHash: '0xwithdraw', + transactionFrom: USER, + owner: USER, + assets: '1000', + shares: '100', + ...overrides + } +} + +function createTransferEvent(overrides: Partial): TransferEvent { + return { + id: 'transfer', + vaultAddress: UNDERLYING_VAULT, + chainId: 1, + blockNumber: 1, + blockTimestamp: 100, + logIndex: 1, + transactionHash: '0xtransfer', + transactionFrom: USER, + sender: ROUTER, + receiver: USER, + value: '100', + ...overrides + } +} + +describe('processRawPnlEvents', () => { + it('moves a family lot from vault shares to staked shares on a direct stake', () => { + const underlyingDeposit = createDepositEvent() + const stakeDeposit = createDepositEvent({ + id: 'stake-deposit', + vaultAddress: STAKING_VAULT, + blockNumber: 2, + blockTimestamp: 200, + logIndex: 2, + transactionHash: '0xstake', + assets: '100', + shares: '100' + }) + const underlyingTransferOut = createTransferEvent({ + id: 'underlying-transfer-out', + blockNumber: 2, + blockTimestamp: 200, + logIndex: 1, + transactionHash: '0xstake', + vaultAddress: UNDERLYING_VAULT, + sender: USER, + receiver: STAKING_VAULT, + value: '100' + }) + + const ledger = processRawPnlEvents( + buildRawPnlEvents({ + addressEvents: { + deposits: [underlyingDeposit, stakeDeposit], + withdrawals: [], + transfersIn: [], + transfersOut: [underlyingTransferOut] + }, + transactionEvents: { + deposits: [underlyingDeposit, stakeDeposit], + withdrawals: [], + transfers: [underlyingTransferOut] + } + }), + USER + ).get(FAMILY_KEY) + + expect(ledger).toBeDefined() + expect(ledger?.vaultLots).toEqual([]) + expect(ledger?.stakedLots).toEqual([{ shares: 100n, costBasis: 1000n, acquiredAt: 100 }]) + expect(ledger?.realizedEntries).toEqual([]) + expect(ledger?.totalDepositedAssets).toBe(1000n) + expect(ledger?.eventCounts.underlyingDeposits).toBe(1) + expect(ledger?.eventCounts.stakes).toBe(1) + }) + + it('moves a family lot from staked shares back to vault shares on unstake without realizing pnl', () => { + const underlyingDeposit = createDepositEvent() + const stakeDeposit = createDepositEvent({ + id: 'stake-deposit', + vaultAddress: STAKING_VAULT, + blockNumber: 2, + blockTimestamp: 200, + logIndex: 2, + transactionHash: '0xstake', + assets: '100', + shares: '100' + }) + const underlyingTransferOut = createTransferEvent({ + id: 'underlying-transfer-out', + blockNumber: 2, + blockTimestamp: 200, + logIndex: 1, + transactionHash: '0xstake', + vaultAddress: UNDERLYING_VAULT, + sender: USER, + receiver: STAKING_VAULT, + value: '100' + }) + const stakeWithdraw = createWithdrawEvent({ + id: 'stake-withdraw', + vaultAddress: STAKING_VAULT, + blockNumber: 3, + blockTimestamp: 300, + logIndex: 1, + transactionHash: '0xunstake', + owner: USER, + assets: '100', + shares: '100' + }) + const underlyingTransferIn = createTransferEvent({ + id: 'underlying-transfer-in', + blockNumber: 3, + blockTimestamp: 300, + logIndex: 2, + transactionHash: '0xunstake', + vaultAddress: UNDERLYING_VAULT, + sender: STAKING_VAULT, + receiver: USER, + value: '100' + }) + + const ledger = processRawPnlEvents( + buildRawPnlEvents({ + addressEvents: { + deposits: [underlyingDeposit, stakeDeposit], + withdrawals: [stakeWithdraw], + transfersIn: [underlyingTransferIn], + transfersOut: [underlyingTransferOut] + }, + transactionEvents: { + deposits: [underlyingDeposit, stakeDeposit], + withdrawals: [stakeWithdraw], + transfers: [underlyingTransferOut, underlyingTransferIn] + } + }), + USER + ).get(FAMILY_KEY) + + expect(ledger).toBeDefined() + expect(ledger?.vaultLots).toEqual([{ shares: 100n, costBasis: 1000n, acquiredAt: 100 }]) + expect(ledger?.stakedLots).toEqual([]) + expect(ledger?.realizedEntries).toEqual([]) + expect(ledger?.eventCounts.stakes).toBe(1) + expect(ledger?.eventCounts.unstakes).toBe(1) + }) + + it('uses the underlying vault deposit as cost basis for router stakes into staking vaults', () => { + const underlyingDeposit = createDepositEvent({ + id: 'router-underlying-deposit', + owner: ROUTER, + sender: ROUTER, + transactionHash: '0xrouter-stake', + blockTimestamp: 200, + blockNumber: 2, + assets: '1000', + shares: '900' + }) + const stakingTransferIn = createTransferEvent({ + id: 'router-staking-transfer-in', + vaultAddress: STAKING_VAULT, + transactionHash: '0xrouter-stake', + blockTimestamp: 200, + blockNumber: 2, + sender: ROUTER, + receiver: USER, + value: '900' + }) + + const ledger = processRawPnlEvents( + buildRawPnlEvents({ + addressEvents: { + deposits: [], + withdrawals: [], + transfersIn: [stakingTransferIn], + transfersOut: [] + }, + transactionEvents: { + deposits: [underlyingDeposit], + withdrawals: [], + transfers: [stakingTransferIn] + } + }), + USER + ).get(FAMILY_KEY) + + expect(ledger).toBeDefined() + expect(ledger?.vaultLots).toEqual([]) + expect(ledger?.stakedLots).toEqual([{ shares: 900n, costBasis: 1000n, acquiredAt: 200 }]) + expect(ledger?.unknownCostBasisTransferInCount).toBe(0) + expect(ledger?.eventCounts.underlyingDeposits).toBe(1) + expect(ledger?.eventCounts.externalTransfersIn).toBe(0) + }) + + it('uses owner deposits even when the transaction sender is an external distributor', () => { + const distributorDeposit = createDepositEvent({ + id: 'distributor-deposit', + transactionHash: '0xdistributor', + transactionFrom: '0x2222222222222222222222222222222222222222', + sender: '0x3333333333333333333333333333333333333333', + owner: USER, + assets: '1000', + shares: '900' + }) + + const ledger = processRawPnlEvents( + buildRawPnlEvents({ + addressEvents: { + deposits: [distributorDeposit], + withdrawals: [], + transfersIn: [], + transfersOut: [] + }, + transactionEvents: { + deposits: [], + withdrawals: [], + transfers: [] + } + }), + USER + ).get(FAMILY_KEY) + + expect(ledger).toBeDefined() + expect(ledger?.vaultLots).toEqual([{ shares: 900n, costBasis: 1000n, acquiredAt: 100 }]) + expect(ledger?.stakedLots).toEqual([]) + expect(ledger?.totalDepositedAssets).toBe(1000n) + expect(ledger?.eventCounts.underlyingDeposits).toBe(1) + }) + + it('classifies known reward distributor receipts as zero-basis rewards instead of unknown transfer-ins', () => { + const rewardTransferIn = createTransferEvent({ + id: 'reward-transfer-in', + transactionHash: '0xreward', + vaultAddress: REWARD_VAULT, + sender: REWARD_DISTRIBUTOR, + receiver: USER, + value: '100' + }) + + const ledger = processRawPnlEvents( + buildRawPnlEvents({ + addressEvents: { + deposits: [], + withdrawals: [], + transfersIn: [rewardTransferIn], + transfersOut: [] + }, + transactionEvents: { + deposits: [], + withdrawals: [], + transfers: [rewardTransferIn] + } + }), + USER + ).get(REWARD_FAMILY_KEY) + + expect(ledger).toBeDefined() + expect(ledger?.vaultLots).toEqual([{ shares: 100n, costBasis: 0n, acquiredAt: 100 }]) + expect(ledger?.stakedLots).toEqual([]) + expect(ledger?.unknownCostBasisTransferInCount).toBe(0) + expect(ledger?.unknownTransferInEntries).toEqual([]) + expect(ledger?.rewardTransferInEntries).toEqual([ + { + timestamp: 100, + shares: 100n, + location: 'vault', + distributor: REWARD_DISTRIBUTOR + } + ]) + expect(ledger?.eventCounts.rewardTransfersIn).toBe(1) + expect(ledger?.eventCounts.externalTransfersIn).toBe(0) + expect(ledger?.debugJournal.at(-1)?.view).toBe('reward_in_vault') + }) + + it('classifies known Katana reward distributor receipts as zero-basis rewards instead of unknown transfer-ins', () => { + const rewardTransferIn = createTransferEvent({ + id: 'katana-reward-transfer-in', + chainId: 747474, + transactionHash: '0xkatana-reward', + vaultAddress: KATANA_REWARD_VAULT, + sender: KATANA_REWARD_DISTRIBUTOR, + receiver: USER, + value: '8523506' + }) + + const ledger = processRawPnlEvents( + buildRawPnlEvents({ + addressEvents: { + deposits: [], + withdrawals: [], + transfersIn: [rewardTransferIn], + transfersOut: [] + }, + transactionEvents: { + deposits: [], + withdrawals: [], + transfers: [rewardTransferIn] + } + }), + USER + ).get(KATANA_REWARD_FAMILY_KEY) + + expect(ledger).toBeDefined() + expect(ledger?.vaultLots).toEqual([{ shares: 8523506n, costBasis: 0n, acquiredAt: 100 }]) + expect(ledger?.unknownCostBasisTransferInCount).toBe(0) + expect(ledger?.unknownTransferInEntries).toEqual([]) + expect(ledger?.rewardTransferInEntries).toEqual([ + { + timestamp: 100, + shares: 8523506n, + location: 'vault', + distributor: KATANA_REWARD_DISTRIBUTOR + } + ]) + expect(ledger?.eventCounts.rewardTransfersIn).toBe(1) + expect(ledger?.eventCounts.externalTransfersIn).toBe(0) + expect(ledger?.debugJournal.at(-1)?.view).toBe('reward_in_vault') + }) + + it.each( + KATANA_MULTISIG_REWARD_DISTRIBUTORS.flatMap((distributor) => + KATANA_MULTISIG_REWARD_VAULTS.map((vaultAddress) => [distributor, vaultAddress] as const) + ) + )('classifies Katana multisig reward receipts from %s for %s as zero-basis rewards instead of unknown transfer-ins', (distributor, vaultAddress) => { + const rewardTransferIn = createTransferEvent({ + id: `katana-multisig-reward-transfer-in-${distributor}-${vaultAddress}`, + chainId: 747474, + transactionHash: '0xkatana-multisig-reward', + vaultAddress, + sender: distributor, + receiver: USER, + value: '100' + }) + + const ledger = processRawPnlEvents( + buildRawPnlEvents({ + addressEvents: { + deposits: [], + withdrawals: [], + transfersIn: [rewardTransferIn], + transfersOut: [] + }, + transactionEvents: { + deposits: [], + withdrawals: [], + transfers: [rewardTransferIn] + } + }), + USER + ).get(`747474:${vaultAddress}`) + + expect(ledger).toBeDefined() + expect(ledger?.vaultLots).toEqual([{ shares: 100n, costBasis: 0n, acquiredAt: 100 }]) + expect(ledger?.unknownCostBasisTransferInCount).toBe(0) + expect(ledger?.unknownTransferInEntries).toEqual([]) + expect(ledger?.rewardTransferInEntries).toEqual([ + { + timestamp: 100, + shares: 100n, + location: 'vault', + distributor + } + ]) + expect(ledger?.eventCounts.rewardTransfersIn).toBe(1) + expect(ledger?.eventCounts.externalTransfersIn).toBe(0) + expect(ledger?.debugJournal.at(-1)?.view).toBe('reward_in_vault') + }) + + it('realizes pnl from underlying vault withdrawals when staked shares exit through a router', () => { + const underlyingDeposit = createDepositEvent({ + id: 'router-underlying-deposit', + owner: ROUTER, + sender: ROUTER, + transactionHash: '0xrouter-stake', + blockTimestamp: 200, + blockNumber: 2, + assets: '1000', + shares: '900' + }) + const stakingTransferIn = createTransferEvent({ + id: 'router-staking-transfer-in', + vaultAddress: STAKING_VAULT, + transactionHash: '0xrouter-stake', + blockTimestamp: 200, + blockNumber: 2, + sender: ROUTER, + receiver: USER, + value: '900' + }) + const stakingTransferOut = createTransferEvent({ + id: 'router-staking-transfer-out', + vaultAddress: STAKING_VAULT, + transactionHash: '0xrouter-exit', + blockTimestamp: 300, + blockNumber: 3, + sender: USER, + receiver: ROUTER, + value: '900' + }) + const underlyingWithdraw = createWithdrawEvent({ + id: 'router-underlying-withdraw', + owner: ROUTER, + transactionHash: '0xrouter-exit', + blockTimestamp: 300, + blockNumber: 3, + assets: '1100', + shares: '900' + }) + + const ledger = processRawPnlEvents( + buildRawPnlEvents({ + addressEvents: { + deposits: [], + withdrawals: [], + transfersIn: [stakingTransferIn], + transfersOut: [stakingTransferOut] + }, + transactionEvents: { + deposits: [underlyingDeposit], + withdrawals: [underlyingWithdraw], + transfers: [stakingTransferIn, stakingTransferOut] + } + }), + USER + ).get(FAMILY_KEY) + + expect(ledger).toBeDefined() + expect(ledger?.vaultLots).toEqual([]) + expect(ledger?.stakedLots).toEqual([]) + expect(ledger?.realizedEntries).toEqual([ + { + timestamp: 300, + proceedsAssets: 1100n, + basisAssets: 1000n, + pnlAssets: 100n, + consumedLots: [{ shares: 900n, costBasis: 1000n, acquiredAt: 200 }] + } + ]) + expect(ledger?.totalWithdrawnAssets).toBe(1100n) + expect(ledger?.eventCounts.underlyingWithdrawals).toBe(1) + expect(ledger?.withdrawalsWithUnknownCostBasis).toBe(0) + }) + + it('ignores tx-scoped family withdrawals when the user has no address-scoped activity on that family', () => { + const internalWithdraw = createWithdrawEvent({ + id: 'internal-withdraw', + transactionHash: '0xinternal-withdraw', + transactionFrom: USER, + owner: ROUTER, + assets: '1100', + shares: '900' + }) + + const ledgers = processRawPnlEvents( + buildRawPnlEvents({ + addressEvents: { + deposits: [], + withdrawals: [], + transfersIn: [], + transfersOut: [] + }, + transactionEvents: { + deposits: [], + withdrawals: [internalWithdraw], + transfers: [] + } + }), + USER + ) + + expect(ledgers.get(FAMILY_KEY)).toBeUndefined() + }) + + it('drops transfer-only families from direct-interaction pnl ledgers', () => { + const transferOnlyLedger = processRawPnlEvents( + buildRawPnlEvents({ + addressEvents: { + deposits: [], + withdrawals: [], + transfersIn: [createTransferEvent()], + transfersOut: [] + }, + transactionEvents: { + deposits: [], + withdrawals: [], + transfers: [createTransferEvent()] + } + }), + USER + ) + + const filteredLedgers = filterDirectInteractionLedgers(transferOnlyLedger) + + expect(transferOnlyLedger.get(FAMILY_KEY)).toBeDefined() + expect(filteredLedgers.get(FAMILY_KEY)).toBeUndefined() + }) + + it('keeps transfer-only families with current shares in the holdings ledger set', () => { + const transferOnlyLedger = processRawPnlEvents( + buildRawPnlEvents({ + addressEvents: { + deposits: [], + withdrawals: [], + transfersIn: [createTransferEvent()], + transfersOut: [] + }, + transactionEvents: { + deposits: [], + withdrawals: [], + transfers: [createTransferEvent()] + } + }), + USER + ) + + const filteredLedgers = filterRelevantHoldingsLedgers(transferOnlyLedger) + + expect(transferOnlyLedger.get(FAMILY_KEY)).toBeDefined() + expect(filteredLedgers.get(FAMILY_KEY)).toBeDefined() + }) + + it('treats same-vault withdraw and redeposit in the same tx as a basis rollover', () => { + const initialDeposit = createDepositEvent({ + id: 'initial-deposit', + transactionHash: '0xinitial', + assets: '1000', + shares: '100' + }) + const rolloverTransferOut = createTransferEvent({ + id: 'rollover-transfer-out', + transactionHash: '0xrollover', + blockNumber: 2, + blockTimestamp: 200, + logIndex: 1, + sender: USER, + receiver: '0x0000000000000000000000000000000000000000', + value: '100' + }) + const rolloverWithdraw = createWithdrawEvent({ + id: 'rollover-withdraw', + transactionHash: '0xrollover', + blockNumber: 2, + blockTimestamp: 200, + logIndex: 2, + owner: USER, + assets: '1100', + shares: '100' + }) + const rolloverTransferIn = createTransferEvent({ + id: 'rollover-transfer-in', + transactionHash: '0xrollover', + blockNumber: 2, + blockTimestamp: 200, + logIndex: 3, + sender: '0x0000000000000000000000000000000000000000', + receiver: USER, + value: '99' + }) + const rolloverDeposit = createDepositEvent({ + id: 'rollover-deposit', + transactionHash: '0xrollover', + blockNumber: 2, + blockTimestamp: 200, + logIndex: 4, + owner: USER, + sender: ROUTER, + assets: '1100', + shares: '99' + }) + + const ledger = processRawPnlEvents( + buildRawPnlEvents({ + addressEvents: { + deposits: [initialDeposit, rolloverDeposit], + withdrawals: [rolloverWithdraw], + transfersIn: [rolloverTransferIn], + transfersOut: [rolloverTransferOut] + }, + transactionEvents: { + deposits: [initialDeposit, rolloverDeposit], + withdrawals: [rolloverWithdraw], + transfers: [rolloverTransferOut, rolloverTransferIn] + } + }), + USER + ).get(FAMILY_KEY) + + expect(ledger).toBeDefined() + expect(ledger?.vaultLots).toEqual([{ shares: 99n, costBasis: 1000n, acquiredAt: 100 }]) + expect(ledger?.stakedLots).toEqual([]) + expect(ledger?.realizedEntries).toEqual([]) + expect(ledger?.unknownCostBasisTransferInCount).toBe(0) + expect(ledger?.withdrawalsWithUnknownCostBasis).toBe(0) + expect(ledger?.totalDepositedAssets).toBe(1000n) + expect(ledger?.totalWithdrawnAssets).toBe(0n) + expect(ledger?.eventCounts.underlyingDeposits).toBe(1) + expect(ledger?.eventCounts.underlyingWithdrawals).toBe(0) + expect(ledger?.debugJournal.at(-1)?.view).toBe('same_vault_rollover->vault') + }) + + it('rolls known source basis into the destination vault on a known migrator tx', () => { + const sourceFamilyKey = `1:${SOURCE_MIGRATION_VAULT}` + const destinationFamilyKey = `1:${DESTINATION_MIGRATION_VAULT}` + const sourceDeposit = createDepositEvent({ + id: 'source-deposit', + transactionHash: '0xsource-deposit', + vaultAddress: SOURCE_MIGRATION_VAULT, + assets: '1000', + shares: '100' + }) + const migrateOut = createTransferEvent({ + id: 'migrate-out', + transactionHash: '0xmigrate', + blockNumber: 2, + blockTimestamp: 200, + logIndex: 1, + vaultAddress: SOURCE_MIGRATION_VAULT, + sender: USER, + receiver: MIGRATOR, + value: '100' + }) + const migrateBurn = createTransferEvent({ + id: 'migrate-burn', + transactionHash: '0xmigrate', + blockNumber: 2, + blockTimestamp: 200, + logIndex: 2, + vaultAddress: SOURCE_MIGRATION_VAULT, + sender: MIGRATOR, + receiver: ZERO_ADDRESS, + value: '100' + }) + const destinationMint = createTransferEvent({ + id: 'destination-mint', + transactionHash: '0xmigrate', + blockNumber: 2, + blockTimestamp: 200, + logIndex: 3, + vaultAddress: DESTINATION_MIGRATION_VAULT, + sender: ZERO_ADDRESS, + receiver: USER, + value: '80' + }) + const destinationDeposit = createDepositEvent({ + id: 'destination-deposit', + transactionHash: '0xmigrate', + blockNumber: 2, + blockTimestamp: 200, + logIndex: 4, + vaultAddress: DESTINATION_MIGRATION_VAULT, + owner: USER, + sender: USER, + assets: '1100', + shares: '80' + }) + + const ledgers = processRawPnlEvents( + buildRawPnlEvents({ + addressEvents: { + deposits: [sourceDeposit, destinationDeposit], + withdrawals: [], + transfersIn: [destinationMint], + transfersOut: [migrateOut] + }, + transactionEvents: { + deposits: [sourceDeposit, destinationDeposit], + withdrawals: [], + transfers: [migrateOut, migrateBurn, destinationMint] + } + }), + USER + ) + const filteredLedgers = filterDirectInteractionLedgers(ledgers) + const sourceLedger = ledgers.get(sourceFamilyKey) + const destinationLedger = ledgers.get(destinationFamilyKey) + + expect(sourceLedger?.vaultLots).toEqual([]) + expect(sourceLedger?.realizedEntries).toEqual([]) + expect(sourceLedger?.eventCounts.migrationsOut).toBe(1) + expect(destinationLedger?.vaultLots).toEqual([{ shares: 80n, costBasis: 1000n, acquiredAt: 100 }]) + expect(destinationLedger?.totalDepositedAssets).toBe(0n) + expect(destinationLedger?.realizedEntries).toEqual([]) + expect(destinationLedger?.eventCounts.migrationsIn).toBe(1) + expect(destinationLedger?.debugJournal.at(-1)?.view).toBe('migrate_in->vault') + expect(filteredLedgers.get(destinationFamilyKey)).toBeDefined() + }) + + it('marks migrated destination shares as unknown when the source vault only had a mint transfer', () => { + const sourceFamilyKey = `1:${SOURCE_MIGRATION_VAULT}` + const destinationFamilyKey = `1:${DESTINATION_MIGRATION_VAULT}` + const sourceMint = createTransferEvent({ + id: 'source-mint', + transactionHash: '0xsource-mint', + vaultAddress: SOURCE_MIGRATION_VAULT, + sender: ZERO_ADDRESS, + receiver: USER, + value: '158' + }) + const migrateOut = createTransferEvent({ + id: 'migrate-out', + transactionHash: '0xmigrate-unknown', + blockNumber: 2, + blockTimestamp: 200, + logIndex: 1, + vaultAddress: SOURCE_MIGRATION_VAULT, + sender: USER, + receiver: MIGRATOR, + value: '158' + }) + const migrateBurn = createTransferEvent({ + id: 'migrate-burn', + transactionHash: '0xmigrate-unknown', + blockNumber: 2, + blockTimestamp: 200, + logIndex: 2, + vaultAddress: SOURCE_MIGRATION_VAULT, + sender: MIGRATOR, + receiver: ZERO_ADDRESS, + value: '158' + }) + const destinationMint = createTransferEvent({ + id: 'destination-mint', + transactionHash: '0xmigrate-unknown', + blockNumber: 2, + blockTimestamp: 200, + logIndex: 3, + vaultAddress: DESTINATION_MIGRATION_VAULT, + sender: ZERO_ADDRESS, + receiver: USER, + value: '175' + }) + const destinationDeposit = createDepositEvent({ + id: 'destination-deposit', + transactionHash: '0xmigrate-unknown', + blockNumber: 2, + blockTimestamp: 200, + logIndex: 4, + vaultAddress: DESTINATION_MIGRATION_VAULT, + owner: USER, + sender: USER, + assets: '304', + shares: '175' + }) + + const ledgers = processRawPnlEvents( + buildRawPnlEvents({ + addressEvents: { + deposits: [destinationDeposit], + withdrawals: [], + transfersIn: [sourceMint, destinationMint], + transfersOut: [migrateOut] + }, + transactionEvents: { + deposits: [destinationDeposit], + withdrawals: [], + transfers: [sourceMint, migrateOut, migrateBurn, destinationMint] + } + }), + USER + ) + const filteredLedgers = filterDirectInteractionLedgers(ledgers) + const sourceLedger = ledgers.get(sourceFamilyKey) + const destinationLedger = ledgers.get(destinationFamilyKey) + + expect(sourceLedger?.unmatchedTransferOutShares).toBe(158n) + expect(destinationLedger?.vaultLots).toEqual([{ shares: 175n, costBasis: null, acquiredAt: 200 }]) + expect(destinationLedger?.unknownCostBasisTransferInCount).toBe(1) + expect(destinationLedger?.unknownCostBasisTransferInShares).toBe(175n) + expect(destinationLedger?.totalDepositedAssets).toBe(0n) + expect(destinationLedger?.eventCounts.migrationsIn).toBe(1) + expect(filteredLedgers.get(sourceFamilyKey)).toBeUndefined() + expect(filteredLedgers.get(destinationFamilyKey)).toBeDefined() + }) + + it('treats the Yearn 4626 router as a valid migration router', () => { + const destinationFamilyKey = `1:${DESTINATION_MIGRATION_VAULT}` + const sourceDeposit = createDepositEvent({ + id: 'source-deposit-router', + transactionHash: '0xsource-deposit-router', + vaultAddress: SOURCE_MIGRATION_VAULT, + assets: '1000', + shares: '100' + }) + const migrateOut = createTransferEvent({ + id: 'migrate-out-router', + transactionHash: '0xmigrate-router', + blockNumber: 2, + blockTimestamp: 200, + logIndex: 1, + vaultAddress: SOURCE_MIGRATION_VAULT, + sender: USER, + receiver: YEARN_4626_ROUTER, + value: '100' + }) + const migrateBurn = createTransferEvent({ + id: 'migrate-burn-router', + transactionHash: '0xmigrate-router', + blockNumber: 2, + blockTimestamp: 200, + logIndex: 2, + vaultAddress: SOURCE_MIGRATION_VAULT, + sender: YEARN_4626_ROUTER, + receiver: ZERO_ADDRESS, + value: '100' + }) + const destinationMint = createTransferEvent({ + id: 'destination-mint-router', + transactionHash: '0xmigrate-router', + blockNumber: 2, + blockTimestamp: 200, + logIndex: 3, + vaultAddress: DESTINATION_MIGRATION_VAULT, + sender: ZERO_ADDRESS, + receiver: USER, + value: '80' + }) + const destinationDeposit = createDepositEvent({ + id: 'destination-deposit-router', + transactionHash: '0xmigrate-router', + blockNumber: 2, + blockTimestamp: 200, + logIndex: 4, + vaultAddress: DESTINATION_MIGRATION_VAULT, + owner: USER, + sender: USER, + assets: '1100', + shares: '80' + }) + + const ledgers = processRawPnlEvents( + buildRawPnlEvents({ + addressEvents: { + deposits: [sourceDeposit, destinationDeposit], + withdrawals: [], + transfersIn: [destinationMint], + transfersOut: [migrateOut] + }, + transactionEvents: { + deposits: [sourceDeposit, destinationDeposit], + withdrawals: [], + transfers: [migrateOut, migrateBurn, destinationMint] + } + }), + USER + ) + + expect(ledgers.get(destinationFamilyKey)?.vaultLots).toEqual([{ shares: 80n, costBasis: 1000n, acquiredAt: 100 }]) + expect(ledgers.get(destinationFamilyKey)?.eventCounts.migrationsIn).toBe(1) + }) + + it('rolls basis through Enso-mediated cross-family vault rollovers', () => { + const sourceFamilyKey = `1:${SOURCE_MIGRATION_VAULT}` + const destinationFamilyKey = `1:${DESTINATION_MIGRATION_VAULT}` + const sourceDeposit = createDepositEvent({ + id: 'source-deposit-enso', + transactionHash: '0xsource-deposit-enso', + vaultAddress: SOURCE_MIGRATION_VAULT, + assets: '1000', + shares: '100' + }) + const migrateOut = createTransferEvent({ + id: 'migrate-out-enso', + transactionHash: '0xenso-rollover', + blockNumber: 2, + blockTimestamp: 200, + logIndex: 1, + vaultAddress: SOURCE_MIGRATION_VAULT, + sender: USER, + receiver: ENSO_EXECUTOR, + value: '100' + }) + const migrateBurn = createTransferEvent({ + id: 'migrate-burn-enso', + transactionHash: '0xenso-rollover', + blockNumber: 2, + blockTimestamp: 200, + logIndex: 2, + vaultAddress: SOURCE_MIGRATION_VAULT, + sender: ENSO_EXECUTOR, + receiver: ZERO_ADDRESS, + value: '100' + }) + const sourceWithdraw = createWithdrawEvent({ + id: 'source-withdraw-enso', + transactionHash: '0xenso-rollover', + blockNumber: 2, + blockTimestamp: 200, + logIndex: 3, + vaultAddress: SOURCE_MIGRATION_VAULT, + owner: ENSO_EXECUTOR, + assets: '1100', + shares: '100' + }) + const ensoStepBurn = createTransferEvent({ + id: 'enso-step-burn', + transactionHash: '0xenso-rollover', + blockNumber: 2, + blockTimestamp: 200, + logIndex: 3, + vaultAddress: ENDO_STEP_VAULT, + sender: DESTINATION_MIGRATION_VAULT, + receiver: ZERO_ADDRESS, + value: '103' + }) + const ensoStepWithdraw = createWithdrawEvent({ + id: 'enso-step-withdraw', + transactionHash: '0xenso-rollover', + blockNumber: 2, + blockTimestamp: 200, + logIndex: 4, + vaultAddress: ENDO_STEP_VAULT, + owner: DESTINATION_MIGRATION_VAULT, + assets: '1100', + shares: '103' + }) + const destinationMint = createTransferEvent({ + id: 'destination-mint-enso', + transactionHash: '0xenso-rollover', + blockNumber: 2, + blockTimestamp: 200, + logIndex: 5, + vaultAddress: DESTINATION_MIGRATION_VAULT, + sender: ZERO_ADDRESS, + receiver: USER, + value: '81' + }) + const destinationDeposit = createDepositEvent({ + id: 'destination-deposit-enso', + transactionHash: '0xenso-rollover', + blockNumber: 2, + blockTimestamp: 200, + logIndex: 6, + vaultAddress: DESTINATION_MIGRATION_VAULT, + owner: USER, + sender: ENSO_EXECUTOR, + assets: '1100', + shares: '80' + }) + + const ledgers = processRawPnlEvents( + buildRawPnlEvents({ + addressEvents: { + deposits: [sourceDeposit, destinationDeposit], + withdrawals: [], + transfersIn: [destinationMint], + transfersOut: [migrateOut] + }, + transactionEvents: { + deposits: [sourceDeposit, destinationDeposit], + withdrawals: [sourceWithdraw, ensoStepWithdraw], + transfers: [migrateOut, migrateBurn, ensoStepBurn, destinationMint] + } + }), + USER + ) + + expect(ledgers.get(sourceFamilyKey)?.vaultLots).toEqual([]) + expect(ledgers.get(sourceFamilyKey)?.eventCounts.migrationsOut).toBe(1) + expect(ledgers.get(destinationFamilyKey)?.vaultLots).toEqual([{ shares: 81n, costBasis: 1000n, acquiredAt: 100 }]) + expect(ledgers.get(destinationFamilyKey)?.unknownTransferInEntries).toEqual([]) + expect(ledgers.get(destinationFamilyKey)?.eventCounts.migrationsIn).toBe(1) + expect(ledgers.get(destinationFamilyKey)?.debugJournal.at(-1)?.view).toBe('migrate_in->vault') + }) + + it('rolls basis from ysyBOLD withdrawals into yBOLD receipts', () => { + const outerDepositMint = createTransferEvent({ + id: 'ysybold-mint', + transactionHash: '0xysybold-deposit', + vaultAddress: YSYBOLD_VAULT, + sender: ZERO_ADDRESS, + receiver: USER, + value: '94' + }) + const outerDeposit = createDepositEvent({ + id: 'ysybold-deposit', + transactionHash: '0xysybold-deposit', + vaultAddress: YSYBOLD_VAULT, + owner: USER, + sender: USER, + assets: '100', + shares: '94' + }) + const outerBurn = createTransferEvent({ + id: 'ysybold-burn', + transactionHash: '0xysybold-withdraw', + blockNumber: 2, + blockTimestamp: 200, + logIndex: 1, + vaultAddress: YSYBOLD_VAULT, + sender: USER, + receiver: ZERO_ADDRESS, + value: '94' + }) + const innerTransfer = createTransferEvent({ + id: 'ybold-transfer-in', + transactionHash: '0xysybold-withdraw', + blockNumber: 2, + blockTimestamp: 200, + logIndex: 2, + vaultAddress: YBOLD_VAULT, + sender: YSYBOLD_VAULT, + receiver: USER, + value: '100' + }) + const outerWithdraw = createWithdrawEvent({ + id: 'ysybold-withdraw', + transactionHash: '0xysybold-withdraw', + blockNumber: 2, + blockTimestamp: 200, + logIndex: 3, + vaultAddress: YSYBOLD_VAULT, + owner: USER, + assets: '100', + shares: '94' + }) + + const ledgers = processRawPnlEvents( + buildRawPnlEvents({ + addressEvents: { + deposits: [outerDeposit], + withdrawals: [outerWithdraw], + transfersIn: [outerDepositMint, innerTransfer], + transfersOut: [outerBurn] + }, + transactionEvents: { + deposits: [outerDeposit], + withdrawals: [outerWithdraw], + transfers: [outerDepositMint, outerBurn, innerTransfer] + } + }), + USER, + BOLD_STACK_METADATA + ) + const outerLedger = ledgers.get(YSYBOLD_FAMILY_KEY) + const innerLedger = ledgers.get(YBOLD_FAMILY_KEY) + + expect(outerLedger?.vaultLots).toEqual([]) + expect(outerLedger?.realizedEntries).toEqual([]) + expect(outerLedger?.unknownWithdrawalEntries).toEqual([]) + expect(outerLedger?.eventCounts.underlyingDeposits).toBe(1) + expect(outerLedger?.eventCounts.underlyingWithdrawals).toBe(1) + expect(outerLedger?.eventCounts.migrationsOut).toBe(1) + expect(outerLedger?.totalWithdrawnAssets).toBe(100n) + expect(innerLedger?.vaultLots).toEqual([{ shares: 100n, costBasis: 100n, acquiredAt: 100 }]) + expect(innerLedger?.unknownTransferInEntries).toEqual([]) + expect(innerLedger?.eventCounts.migrationsIn).toBe(1) + expect(innerLedger?.debugJournal.at(-1)?.view).toBe('migrate_in->vault') + }) +}) diff --git a/api/lib/holdings/services/pnl.ts b/api/lib/holdings/services/pnl.ts new file mode 100644 index 000000000..9848ad78e --- /dev/null +++ b/api/lib/holdings/services/pnl.ts @@ -0,0 +1,2777 @@ +import type { DepositEvent, TransferEvent, VaultMetadata, WithdrawEvent } from '../types' +import { enrichRawPnlEventsWithCowTradeAcquisitions } from './cow' +import { debugLog, debugTable, getHoldingsDebugFilters } from './debug' +import { + fetchHistoricalPricesForTokenTimestamps, + getChainPrefix, + getPriceAtTimestamp, + type THistoricalPriceRequest +} from './defillama' +import { + fetchRawUserPnlEvents, + type HoldingsEventFetchType, + type HoldingsEventPaginationMode, + type RawPnlEventContext, + type VaultVersion +} from './graphql' +import { fetchMultipleVaultsPPS, getPPS } from './kong' +import { + deriveNestedVaultAssetPriceData, + expandNestedVaultAssetPriceRequests, + getAssetVaultMetadataLookupIdentifiers, + getNestedVaultPpsIdentifiersFromPriceRequests, + mergeVaultIdentifiers +} from './nestedVaultPrices' +import { + formatAmount, + isKnownCompatibleAssetVaultRollover, + isKnownZeroBasisRewardDistribution, + KNOWN_VAULT_ROLLOVER_INTERMEDIARIES, + lowerCaseAddress, + minBigInt, + negativeBigIntMagnitude, + positiveBigInt, + sumKnownCostBasis, + sumShares, + toVaultKey, + ZERO, + ZERO_ADDRESS +} from './pnlShared' + +export type { + HoldingsPnLDrilldownResponse, + HoldingsPnLDrilldownVault, + HoldingsPnLResponse, + HoldingsPnLVault, + UnknownTransferInPnlMode +} from './pnlTypes' + +import type { + FamilyPnlLedger, + HoldingsPnLDrilldownResponse, + HoldingsPnLDrilldownVault, + HoldingsPnLResponse, + HoldingsPnLVault, + TLocation, + TLot, + TLotSummary, + TPnlDebugJournalRow, + TRawPnlEvent, + TRawScopes, + UnknownTransferInPnlMode +} from './pnlTypes' +import { + applyUnknownTransferInModeAdjustment, + createEmptyHoldingsPnlResponse, + createMissingMetadataPnlVault, + summarizePnlVaults, + toHoldingsPnlEventCounts +} from './pnlValuation' +import { getFamilyVaultAddress, getStakingVaultAddress, isStakingVault } from './staking' +import { fetchMultipleVaultsMetadata } from './vaults' + +function createLot(shares: bigint, costBasis: bigint | null, acquiredAt?: number): TLot { + return acquiredAt === undefined ? { shares, costBasis } : { shares, costBasis, acquiredAt } +} + +function cloneLots(lots: TLot[]): TLot[] { + return lots.map((lot) => createLot(lot.shares, lot.costBasis, lot.acquiredAt)) +} + +function summarizeLots(lots: TLot[]): TLotSummary { + const knownLots = lots.filter((lot) => lot.costBasis !== null) + const unknownLots = lots.filter((lot) => lot.costBasis === null) + + return { + lotCount: lots.length, + totalShares: sumShares(lots).toString(), + knownShares: sumShares(knownLots).toString(), + unknownShares: sumShares(unknownLots).toString(), + totalKnownCostBasis: sumKnownCostBasis(knownLots).toString() + } +} + +async function resolveNestedVaultAssetMetadata( + vaultMetadata: Map +): Promise> { + const assetVaultIdentifiers = getAssetVaultMetadataLookupIdentifiers(vaultMetadata) + + if (assetVaultIdentifiers.length === 0) { + return vaultMetadata + } + + const assetVaultMetadata = await fetchMultipleVaultsMetadata(assetVaultIdentifiers, { skipSnapshotFallback: true }) + return new Map([...vaultMetadata, ...assetVaultMetadata]) +} + +function serializeLots( + lots: TLot[], + shareDecimals?: number, + assetDecimals?: number +): Array<{ + index: number + shares: string + sharesFormatted?: number + costBasis: string | null + costBasisFormatted?: number | null + acquiredAt?: number +}> { + return lots.map((lot, index) => ({ + index, + shares: lot.shares.toString(), + sharesFormatted: shareDecimals === undefined ? undefined : formatAmount(lot.shares, shareDecimals), + costBasis: lot.costBasis?.toString() ?? null, + costBasisFormatted: + lot.costBasis === null || assetDecimals === undefined ? null : formatAmount(lot.costBasis, assetDecimals), + acquiredAt: lot.acquiredAt + })) +} + +function getTokenPriceForTimestamp( + tokenPriceMap: Map | null, + timestamp: number | undefined, + fallbackTokenPrice: number +): number { + if (!timestamp || timestamp <= 0) { + return fallbackTokenPrice + } + + return tokenPriceMap ? getPriceAtTimestamp(tokenPriceMap, timestamp) : fallbackTokenPrice +} + +function getKnownLotsCostBasisUsd( + lots: TLot[], + assetDecimals: number, + tokenPriceMap: Map | null, + fallbackTokenPrice: number +): number { + return lots.reduce((total, lot) => { + if (lot.costBasis === null) { + return total + } + + return ( + total + + formatAmount(lot.costBasis, assetDecimals) * + getTokenPriceForTimestamp(tokenPriceMap, lot.acquiredAt, fallbackTokenPrice) + ) + }, 0) +} + +function countTxEventsByKind(txFamilyEvents: TRawPnlEvent[]): string { + const counts = txFamilyEvents.reduce( + (totals, event) => { + if (event.kind === 'deposit') totals.deposits += 1 + if (event.kind === 'withdrawal') totals.withdrawals += 1 + if (event.kind === 'transfer') totals.transfers += 1 + return totals + }, + { deposits: 0, withdrawals: 0, transfers: 0 } + ) + + return `dep:${counts.deposits} wd:${counts.withdrawals} xfer:${counts.transfers}` +} + +function serializeJournalRows( + rows: TPnlDebugJournalRow[], + shareDecimals?: number, + assetDecimals?: number +): Array> { + return rows.map((row) => ({ + timestamp: row.timestamp, + txHash: row.txHash, + view: row.view, + direct: row.hasAddressActivity ? 'yes' : 'no', + rawEvents: row.rawEvents, + depShares: shareDecimals === undefined ? row.depositShares : formatAmount(BigInt(row.depositShares), shareDecimals), + depAssets: assetDecimals === undefined ? row.depositAssets : formatAmount(BigInt(row.depositAssets), assetDecimals), + wdShares: + shareDecimals === undefined ? row.withdrawShares : formatAmount(BigInt(row.withdrawShares), shareDecimals), + wdAssets: + assetDecimals === undefined ? row.withdrawAssets : formatAmount(BigInt(row.withdrawAssets), assetDecimals), + stake: shareDecimals === undefined ? row.stakeShares : formatAmount(BigInt(row.stakeShares), shareDecimals), + unstake: shareDecimals === undefined ? row.unstakeShares : formatAmount(BigInt(row.unstakeShares), shareDecimals), + rewardVault: + shareDecimals === undefined + ? row.rewardInVaultShares + : formatAmount(BigInt(row.rewardInVaultShares), shareDecimals), + rewardStaked: + shareDecimals === undefined + ? row.rewardInStakedShares + : formatAmount(BigInt(row.rewardInStakedShares), shareDecimals), + unkVault: + shareDecimals === undefined + ? row.unknownInVaultShares + : formatAmount(BigInt(row.unknownInVaultShares), shareDecimals), + unkStaked: + shareDecimals === undefined + ? row.unknownInStakedShares + : formatAmount(BigInt(row.unknownInStakedShares), shareDecimals), + outVault: + shareDecimals === undefined + ? row.transferOutVaultShares + : formatAmount(BigInt(row.transferOutVaultShares), shareDecimals), + outStaked: + shareDecimals === undefined + ? row.transferOutStakedShares + : formatAmount(BigInt(row.transferOutStakedShares), shareDecimals), + pnlShares: + shareDecimals === undefined + ? row.realizedKnownShares + : formatAmount(BigInt(row.realizedKnownShares), shareDecimals), + proceeds: + assetDecimals === undefined + ? row.realizedProceedsAssets + : formatAmount(BigInt(row.realizedProceedsAssets), assetDecimals), + basisConsumed: + assetDecimals === undefined + ? row.realizedBasisAssets + : formatAmount(BigInt(row.realizedBasisAssets), assetDecimals), + realizedPnl: + assetDecimals === undefined ? row.realizedPnlAssets : formatAmount(BigInt(row.realizedPnlAssets), assetDecimals) + })) +} + +type TPnlComputationArtifacts = { + userAddress: string + version: VaultVersion + unknownTransferInPnlMode: UnknownTransferInPnlMode + currentTimestamp: number + vaults: FamilyPnlLedger[] + vaultMetadata: Map + ppsData: Map> + priceData: Map> + debugTxLedgerKeys: Set +} + +function filterVaultsByAuthoritativeVersion( + vaults: FamilyPnlLedger[], + vaultMetadata: Map, + version: VaultVersion +): FamilyPnlLedger[] { + 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 formatLotSummaryForResponse( + summary: TLotSummary, + shareDecimals: number, + assetDecimals: number +): HoldingsPnLDrilldownVault['journal'][number]['vaultLotsBefore'] { + return { + ...summary, + totalSharesFormatted: formatAmount(BigInt(summary.totalShares), shareDecimals), + knownSharesFormatted: formatAmount(BigInt(summary.knownShares), shareDecimals), + unknownSharesFormatted: formatAmount(BigInt(summary.unknownShares), shareDecimals), + totalKnownCostBasisFormatted: formatAmount(BigInt(summary.totalKnownCostBasis), assetDecimals) + } +} + +function serializeLotForResponse(args: { + lot: TLot + index: number + shareDecimals: number + assetDecimals: number + resolvedPricePerShare: number + currentTokenPrice: number + ppsMap: Map | undefined + tokenPriceMap: Map | null +}): HoldingsPnLDrilldownVault['currentLots']['vault'][number] { + const { lot, index, shareDecimals, assetDecimals, resolvedPricePerShare, currentTokenPrice, ppsMap, tokenPriceMap } = + args + const acquiredAt = lot.acquiredAt ?? null + const sharesFormatted = formatAmount(lot.shares, shareDecimals) + const costBasisFormatted = lot.costBasis === null ? null : formatAmount(lot.costBasis, assetDecimals) + const pricePerShareAtAcquisition = + acquiredAt === null + ? resolvedPricePerShare + : ppsMap + ? (getPPS(ppsMap, acquiredAt) ?? resolvedPricePerShare) + : resolvedPricePerShare + const tokenPriceAtAcquisition = + acquiredAt === null ? currentTokenPrice : getTokenPriceForTimestamp(tokenPriceMap, acquiredAt, currentTokenPrice) + const currentUnderlying = sharesFormatted * resolvedPricePerShare + + return { + index, + shares: lot.shares.toString(), + sharesFormatted, + costBasis: lot.costBasis?.toString() ?? null, + costBasisFormatted, + acquiredAt, + costBasisUsd: costBasisFormatted === null ? null : costBasisFormatted * tokenPriceAtAcquisition, + pricePerShareAtAcquisition, + tokenPriceAtAcquisition, + currentUnderlying, + currentValueUsd: currentUnderlying * currentTokenPrice + } +} + +function serializeRealizedEntryForResponse(args: { + entry: FamilyPnlLedger['realizedEntries'][number] + shareDecimals: number + assetDecimals: number + currentTokenPrice: number + tokenPriceMap: Map | null + resolvedPricePerShare: number + ppsMap: Map | undefined +}): HoldingsPnLDrilldownVault['realizedEntries'][number] { + const { entry, shareDecimals, assetDecimals, currentTokenPrice, tokenPriceMap, resolvedPricePerShare, ppsMap } = args + const realizedTokenPrice = getTokenPriceForTimestamp(tokenPriceMap, entry.timestamp, currentTokenPrice) + const basisUsd = getKnownLotsCostBasisUsd(entry.consumedLots, assetDecimals, tokenPriceMap, currentTokenPrice) + + return { + timestamp: entry.timestamp, + proceedsAssets: entry.proceedsAssets.toString(), + proceedsUnderlying: formatAmount(entry.proceedsAssets, assetDecimals), + proceedsUsd: formatAmount(entry.proceedsAssets, assetDecimals) * realizedTokenPrice, + basisAssets: entry.basisAssets.toString(), + basisUnderlying: formatAmount(entry.basisAssets, assetDecimals), + basisUsd, + pnlAssets: entry.pnlAssets.toString(), + pnlUnderlying: formatAmount(entry.pnlAssets, assetDecimals), + pnlUsd: formatAmount(entry.proceedsAssets, assetDecimals) * realizedTokenPrice - basisUsd, + consumedLots: entry.consumedLots.map((lot, index) => + serializeLotForResponse({ + lot, + index, + shareDecimals, + assetDecimals, + resolvedPricePerShare, + currentTokenPrice, + ppsMap, + tokenPriceMap + }) + ) + } +} + +function serializeUnknownTransferInEntryForResponse(args: { + entry: FamilyPnlLedger['unknownTransferInEntries'][number] + shareDecimals: number + currentTokenPrice: number + resolvedPricePerShare: number + ppsMap: Map | undefined + tokenPriceMap: Map | null +}): HoldingsPnLDrilldownVault['unknownTransferInEntries'][number] { + const { entry, shareDecimals, currentTokenPrice, resolvedPricePerShare, ppsMap, tokenPriceMap } = args + const pricePerShareAtReceipt = ppsMap + ? (getPPS(ppsMap, entry.timestamp) ?? resolvedPricePerShare) + : resolvedPricePerShare + const tokenPriceAtReceipt = getTokenPriceForTimestamp(tokenPriceMap, entry.timestamp, currentTokenPrice) + const sharesFormatted = formatAmount(entry.shares, shareDecimals) + const receiptUnderlying = sharesFormatted * pricePerShareAtReceipt + + return { + timestamp: entry.timestamp, + location: entry.location, + shares: entry.shares.toString(), + sharesFormatted, + pricePerShareAtReceipt, + tokenPriceAtReceipt, + receiptUnderlying, + receiptValueUsd: receiptUnderlying * tokenPriceAtReceipt + } +} + +function serializeRewardTransferInEntryForResponse(args: { + entry: FamilyPnlLedger['rewardTransferInEntries'][number] + shareDecimals: number + currentTokenPrice: number + resolvedPricePerShare: number + ppsMap: Map | undefined + tokenPriceMap: Map | null +}): HoldingsPnLDrilldownVault['rewardTransferInEntries'][number] { + const { entry, shareDecimals, currentTokenPrice, resolvedPricePerShare, ppsMap, tokenPriceMap } = args + const pricePerShareAtReceipt = ppsMap + ? (getPPS(ppsMap, entry.timestamp) ?? resolvedPricePerShare) + : resolvedPricePerShare + const tokenPriceAtReceipt = getTokenPriceForTimestamp(tokenPriceMap, entry.timestamp, currentTokenPrice) + const sharesFormatted = formatAmount(entry.shares, shareDecimals) + const receiptUnderlying = sharesFormatted * pricePerShareAtReceipt + + return { + timestamp: entry.timestamp, + location: entry.location, + distributor: entry.distributor, + shares: entry.shares.toString(), + sharesFormatted, + pricePerShareAtReceipt, + tokenPriceAtReceipt, + receiptUnderlying, + receiptValueUsd: receiptUnderlying * tokenPriceAtReceipt + } +} + +function serializeUnknownWithdrawalEntryForResponse(args: { + entry: FamilyPnlLedger['unknownWithdrawalEntries'][number] + shareDecimals: number + assetDecimals: number + currentTokenPrice: number + tokenPriceMap: Map | null + resolvedPricePerShare: number + ppsMap: Map | undefined +}): HoldingsPnLDrilldownVault['unknownWithdrawalEntries'][number] { + const { entry, shareDecimals, assetDecimals, currentTokenPrice, tokenPriceMap, resolvedPricePerShare, ppsMap } = args + const realizedTokenPrice = getTokenPriceForTimestamp(tokenPriceMap, entry.timestamp, currentTokenPrice) + + return { + timestamp: entry.timestamp, + shares: entry.shares.toString(), + sharesFormatted: formatAmount(entry.shares, shareDecimals), + proceedsAssets: entry.proceedsAssets.toString(), + proceedsUnderlying: formatAmount(entry.proceedsAssets, assetDecimals), + proceedsUsd: formatAmount(entry.proceedsAssets, assetDecimals) * realizedTokenPrice, + consumedLots: entry.consumedLots.map((lot, index) => + serializeLotForResponse({ + lot, + index, + shareDecimals, + assetDecimals, + resolvedPricePerShare, + currentTokenPrice, + ppsMap, + tokenPriceMap + }) + ) + } +} + +function serializeJournalEntryForResponse(args: { + row: TPnlDebugJournalRow + shareDecimals: number + assetDecimals: number +}): HoldingsPnLDrilldownVault['journal'][number] { + const { row, shareDecimals, assetDecimals } = args + + return { + timestamp: row.timestamp, + txHash: row.txHash, + view: row.view, + hasAddressActivity: row.hasAddressActivity, + rawEvents: row.rawEvents, + depositShares: row.depositShares, + depositSharesFormatted: formatAmount(BigInt(row.depositShares), shareDecimals), + depositAssets: row.depositAssets, + depositAssetsFormatted: formatAmount(BigInt(row.depositAssets), assetDecimals), + withdrawShares: row.withdrawShares, + withdrawSharesFormatted: formatAmount(BigInt(row.withdrawShares), shareDecimals), + withdrawAssets: row.withdrawAssets, + withdrawAssetsFormatted: formatAmount(BigInt(row.withdrawAssets), assetDecimals), + stakeShares: row.stakeShares, + stakeSharesFormatted: formatAmount(BigInt(row.stakeShares), shareDecimals), + unstakeShares: row.unstakeShares, + unstakeSharesFormatted: formatAmount(BigInt(row.unstakeShares), shareDecimals), + rewardInVaultShares: row.rewardInVaultShares, + rewardInVaultSharesFormatted: formatAmount(BigInt(row.rewardInVaultShares), shareDecimals), + rewardInStakedShares: row.rewardInStakedShares, + rewardInStakedSharesFormatted: formatAmount(BigInt(row.rewardInStakedShares), shareDecimals), + unknownInVaultShares: row.unknownInVaultShares, + unknownInVaultSharesFormatted: formatAmount(BigInt(row.unknownInVaultShares), shareDecimals), + unknownInStakedShares: row.unknownInStakedShares, + unknownInStakedSharesFormatted: formatAmount(BigInt(row.unknownInStakedShares), shareDecimals), + transferOutVaultShares: row.transferOutVaultShares, + transferOutVaultSharesFormatted: formatAmount(BigInt(row.transferOutVaultShares), shareDecimals), + transferOutStakedShares: row.transferOutStakedShares, + transferOutStakedSharesFormatted: formatAmount(BigInt(row.transferOutStakedShares), shareDecimals), + realizedKnownShares: row.realizedKnownShares, + realizedKnownSharesFormatted: formatAmount(BigInt(row.realizedKnownShares), shareDecimals), + realizedProceedsAssets: row.realizedProceedsAssets, + realizedProceedsAssetsFormatted: formatAmount(BigInt(row.realizedProceedsAssets), assetDecimals), + realizedBasisAssets: row.realizedBasisAssets, + realizedBasisAssetsFormatted: formatAmount(BigInt(row.realizedBasisAssets), assetDecimals), + realizedPnlAssets: row.realizedPnlAssets, + realizedPnlAssetsFormatted: formatAmount(BigInt(row.realizedPnlAssets), assetDecimals), + vaultLotsBefore: formatLotSummaryForResponse(row.vaultLotsBefore, shareDecimals, assetDecimals), + stakedLotsBefore: formatLotSummaryForResponse(row.stakedLotsBefore, shareDecimals, assetDecimals), + vaultLotsAfter: formatLotSummaryForResponse(row.vaultLotsAfter, shareDecimals, assetDecimals), + stakedLotsAfter: formatLotSummaryForResponse(row.stakedLotsAfter, shareDecimals, assetDecimals) + } +} + +function serializeRawEvent(event: TRawPnlEvent): Record { + if (event.kind === 'deposit') { + return { + kind: event.kind, + vaultAddress: event.vaultAddress, + familyVaultAddress: event.familyVaultAddress, + isStakingVault: event.isStakingVault, + transactionHash: event.transactionHash, + logIndex: event.logIndex, + shares: event.shares.toString(), + assets: event.assets.toString(), + owner: event.owner, + sender: event.sender, + scopes: event.scopes + } + } + + if (event.kind === 'withdrawal') { + return { + kind: event.kind, + vaultAddress: event.vaultAddress, + familyVaultAddress: event.familyVaultAddress, + isStakingVault: event.isStakingVault, + transactionHash: event.transactionHash, + logIndex: event.logIndex, + shares: event.shares.toString(), + assets: event.assets.toString(), + owner: event.owner, + scopes: event.scopes + } + } + + return { + kind: event.kind, + vaultAddress: event.vaultAddress, + familyVaultAddress: event.familyVaultAddress, + isStakingVault: event.isStakingVault, + transactionHash: event.transactionHash, + logIndex: event.logIndex, + shares: event.shares.toString(), + sender: event.sender, + receiver: event.receiver, + scopes: event.scopes + } +} + +function matchesLedgerDebugVault(ledger: FamilyPnlLedger, vaultFilter: string | null): boolean { + if (vaultFilter === null) { + return true + } + + return ledger.vaultAddress === vaultFilter || ledger.stakingVaultAddress === vaultFilter +} + +function shouldLogLotTransactions(ledger: FamilyPnlLedger, transactionHash: string): boolean { + const { lotsEnabled, vaultFilter, txFilter } = getHoldingsDebugFilters() + + if (!lotsEnabled || (vaultFilter === null && txFilter === null)) { + return false + } + + if (!matchesLedgerDebugVault(ledger, vaultFilter)) { + return false + } + + if (txFilter !== null && transactionHash.toLowerCase() !== txFilter) { + return false + } + + return true +} + +function shouldIncludeDetailedLotLogs(): boolean { + const { lotsEnabled, vaultFilter, txFilter } = getHoldingsDebugFilters() + return lotsEnabled && (vaultFilter !== null || txFilter !== null) +} + +function getDebugTxLedgerKeys(events: TRawPnlEvent[]): Set { + const { lotsEnabled, txFilter } = getHoldingsDebugFilters() + + if (!lotsEnabled || txFilter === null) { + return new Set() + } + + return events.reduce>((keys, event) => { + if (event.transactionHash.toLowerCase() === txFilter) { + keys.add(toVaultKey(event.chainId, event.familyVaultAddress)) + } + return keys + }, new Set()) +} + +function shouldLogFinalLots(ledger: FamilyPnlLedger, debugTxLedgerKeys: Set): boolean { + const { lotsEnabled, vaultFilter, txFilter } = getHoldingsDebugFilters() + + if (!lotsEnabled) { + return false + } + + if (!matchesLedgerDebugVault(ledger, vaultFilter)) { + return false + } + + if (txFilter !== null) { + return debugTxLedgerKeys.has(toVaultKey(ledger.chainId, ledger.vaultAddress)) + } + + return true +} + +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' + } + }) +} + +export function buildRawPnlEvents(context: RawPnlEventContext): TRawPnlEvent[] { + const merged = new Map() + const eventSources: Array<{ events: Array>; scope: keyof TRawScopes }> = [ + { events: context.addressEvents.deposits.map(normalizeDeposit), scope: 'address' }, + { events: context.addressEvents.withdrawals.map(normalizeWithdrawal), scope: 'address' }, + { events: context.addressEvents.transfersIn.map(normalizeTransfer), scope: 'address' }, + { events: context.addressEvents.transfersOut.map(normalizeTransfer), scope: 'address' }, + { events: context.transactionEvents.deposits.map(normalizeDeposit), scope: 'tx' }, + { events: context.transactionEvents.withdrawals.map(normalizeWithdrawal), scope: 'tx' }, + { events: context.transactionEvents.transfers.map(normalizeTransfer), scope: 'tx' } + ] + + eventSources.forEach(({ events, scope }) => { + events.forEach((event) => { + mergeRawEvent(merged, event, scope) + }) + }) + + return Array.from(merged.values()).sort(compareRawEvents) +} + +function groupEventsByTransaction(events: TRawPnlEvent[]): TRawPnlEvent[][] { + const grouped = events.reduce>((groups, event) => { + const transactionKey = `${event.chainId}:${event.transactionHash}` + const bucket = groups.get(transactionKey) ?? [] + bucket.push(event) + groups.set(transactionKey, bucket) + return groups + }, new Map()) + + return Array.from(grouped.values()).map((txEvents) => [...txEvents].sort(compareRawEvents)) +} + +function groupTransactionEventsByFamily(txEvents: TRawPnlEvent[]): TRawPnlEvent[][] { + const grouped = txEvents.reduce>((groups, event) => { + const familyKey = toVaultKey(event.chainId, event.familyVaultAddress) + const bucket = groups.get(familyKey) ?? [] + bucket.push(event) + groups.set(familyKey, bucket) + return groups + }, new Map()) + + return Array.from(grouped.values()).map((familyEvents) => [...familyEvents].sort(compareRawEvents)) +} + +function createFamilyLedger(chainId: number, familyVaultAddress: string): FamilyPnlLedger { + return { + chainId, + vaultAddress: familyVaultAddress, + stakingVaultAddress: getStakingVaultAddress(chainId, familyVaultAddress), + vaultLots: [], + stakedLots: [], + totalDepositedAssets: ZERO, + totalWithdrawnAssets: ZERO, + unknownCostBasisTransferInCount: 0, + unknownCostBasisTransferInShares: ZERO, + withdrawalsWithUnknownCostBasis: 0, + unmatchedTransferOutCount: 0, + unmatchedTransferOutShares: ZERO, + realizedEntries: [], + rewardTransferInEntries: [], + unknownTransferInEntries: [], + unknownWithdrawalEntries: [], + debugJournal: [], + eventCounts: { + underlyingDeposits: 0, + underlyingWithdrawals: 0, + stakes: 0, + unstakes: 0, + rewardTransfersIn: 0, + externalTransfersIn: 0, + externalTransfersOut: 0, + migrationsIn: 0, + migrationsOut: 0 + } + } +} + +function getOrCreateLedger( + ledgers: Map, + chainId: number, + familyVaultAddress: string +): FamilyPnlLedger { + const vaultKey = toVaultKey(chainId, familyVaultAddress) + const existing = ledgers.get(vaultKey) + + if (existing) { + return existing + } + + const ledger = createFamilyLedger(chainId, familyVaultAddress) + ledgers.set(vaultKey, ledger) + return ledger +} + +function getLocationLots(ledger: FamilyPnlLedger, location: TLocation): TLot[] { + return location === 'vault' ? ledger.vaultLots : ledger.stakedLots +} + +function setLocationLots(ledger: FamilyPnlLedger, location: TLocation, lots: TLot[]): void { + if (location === 'vault') { + ledger.vaultLots = lots + return + } + + ledger.stakedLots = lots +} + +function addLotsToLocation(ledger: FamilyPnlLedger, location: TLocation, lots: TLot[]): void { + const existingLots = getLocationLots(ledger, location) + const nextLots = [...existingLots] + + lots + .filter((lot) => lot.shares > ZERO) + .forEach((lot) => { + nextLots.push(lot) + }) + + setLocationLots(ledger, location, nextLots) +} + +function consumeLots( + lots: TLot[], + targetShares: bigint +): { nextLots: TLot[]; consumedLots: TLot[]; consumedShares: bigint } { + const remaining = { value: targetShares } + const nextLots: TLot[] = [] + const consumedLots: TLot[] = [] + + lots.forEach((lot) => { + if (lot.shares === ZERO) { + return + } + + if (remaining.value === ZERO) { + nextLots.push(lot) + return + } + + const consumedShares = minBigInt(lot.shares, remaining.value) + const remainingShares = lot.shares - consumedShares + const consumedCostBasis = lot.costBasis === null ? null : (lot.costBasis * consumedShares) / lot.shares + const remainingCostBasis = lot.costBasis === null ? null : lot.costBasis - (consumedCostBasis ?? ZERO) + + consumedLots.push(createLot(consumedShares, consumedCostBasis, lot.acquiredAt)) + + if (remainingShares > ZERO) { + nextLots.push(createLot(remainingShares, remainingCostBasis, lot.acquiredAt)) + } + + remaining.value -= consumedShares + }) + + return { + nextLots, + consumedLots: consumedLots.filter((lot) => lot.shares > ZERO), + consumedShares: targetShares - remaining.value + } +} + +function consumeFromLocation( + ledger: FamilyPnlLedger, + location: TLocation, + targetShares: bigint +): { consumedLots: TLot[]; consumedShares: bigint } { + const consumed = consumeLots(getLocationLots(ledger, location), targetShares) + setLocationLots(ledger, location, consumed.nextLots) + return { + consumedLots: consumed.consumedLots, + consumedShares: consumed.consumedShares + } +} + +function moveBetweenLocations(ledger: FamilyPnlLedger, from: TLocation, to: TLocation, targetShares: bigint): bigint { + const consumed = consumeFromLocation(ledger, from, targetShares) + addLotsToLocation(ledger, to, consumed.consumedLots) + return consumed.consumedShares +} + +function calculateUserContractDelta(txEvents: TRawPnlEvent[], userAddress: string, contractAddress: string): bigint { + return txEvents.reduce((delta, event) => { + if (!event.scopes.address || event.vaultAddress !== contractAddress) { + return delta + } + + if (event.kind === 'deposit' && event.owner === userAddress) { + return delta + event.shares + } + + if (event.kind === 'withdrawal' && event.owner === userAddress) { + return delta - event.shares + } + + if (event.kind === 'transfer' && event.receiver === userAddress && event.sender !== ZERO_ADDRESS) { + return delta + event.shares + } + + if (event.kind === 'transfer' && event.sender === userAddress && event.receiver !== ZERO_ADDRESS) { + return delta - event.shares + } + + return delta + }, ZERO) +} + +function sumEventShares(events: T[]): bigint { + return events.reduce((total, event) => total + event.shares, ZERO) +} + +function sumEventAssets(events: T[]): bigint { + return events.reduce((total, event) => total + event.assets, ZERO) +} + +function hasAddressScopedFamilyActivity( + txFamilyEvents: TRawPnlEvent[], + familyVaultAddress: string, + stakingVaultAddress: string | null +): boolean { + return txFamilyEvents.some( + (event) => + event.scopes.address && + (event.vaultAddress === familyVaultAddress || + (stakingVaultAddress !== null && event.vaultAddress === stakingVaultAddress)) + ) +} + +function shouldIncludeFamilyVaultAssetEvent( + event: Extract, + familyHasAddressActivity: boolean +): boolean { + return event.scopes.address || familyHasAddressActivity +} + +function isLedgerEmpty(ledger: FamilyPnlLedger): boolean { + return ( + sumShares(ledger.vaultLots) === ZERO && + sumShares(ledger.stakedLots) === ZERO && + ledger.totalDepositedAssets === ZERO && + ledger.totalWithdrawnAssets === ZERO && + ledger.unknownCostBasisTransferInCount === 0 && + ledger.unmatchedTransferOutCount === 0 && + ledger.realizedEntries.length === 0 + ) +} + +function isDirectInteractionLedger(ledger: FamilyPnlLedger): boolean { + return ( + ledger.totalDepositedAssets > ZERO || + ledger.totalWithdrawnAssets > ZERO || + ledger.realizedEntries.length > 0 || + ledger.eventCounts.migrationsIn > 0 + ) +} + +function hasCurrentShares(ledger: FamilyPnlLedger): boolean { + return sumShares(ledger.vaultLots) > ZERO || sumShares(ledger.stakedLots) > ZERO +} + +function isCurrentTransferOnlyHoldingsLedger(ledger: FamilyPnlLedger): boolean { + return ( + hasCurrentShares(ledger) && + !isDirectInteractionLedger(ledger) && + ledger.realizedEntries.length === 0 && + ledger.unknownWithdrawalEntries.length === 0 && + [...ledger.vaultLots, ...ledger.stakedLots].every((lot) => lot.costBasis === null) + ) +} + +type TDetectedKnownMigration = { + chainId: number + sourceFamilyVaultAddress: string + destinationFamilyVaultAddress: string + transactionHash: string + blockTimestamp: number + sourceTransferShares: bigint + destinationDepositShares: bigint + destinationDepositAssets: bigint + sourceUnderlyingWithdrawCount: number + sourceUnderlyingWithdrawAssets: bigint + destinationUnderlyingDepositCount: number + destinationUnderlyingDepositAssets: bigint +} + +export function filterDirectInteractionLedgers(ledgers: Map): Map { + return Array.from(ledgers.entries()).reduce>((filtered, [key, ledger]) => { + if (isDirectInteractionLedger(ledger)) { + filtered.set(key, ledger) + } + + return filtered + }, new Map()) +} + +export function filterRelevantHoldingsLedgers(ledgers: Map): Map { + return Array.from(ledgers.entries()).reduce>((filtered, [key, ledger]) => { + if (isDirectInteractionLedger(ledger) || hasCurrentShares(ledger)) { + filtered.set(key, ledger) + } + + return filtered + }, new Map()) +} + +function applyRealization( + ledger: FamilyPnlLedger, + totalShares: bigint, + totalAssets: bigint, + stakingShares: bigint, + vaultShares: bigint, + timestamp: number +): { + realizedPnlAssets: bigint + knownShares: bigint + unknownShares: bigint + knownProceedsAssets: bigint + knownCostBasisAssets: bigint + consumedVaultShares: bigint + consumedStakedShares: bigint +} { + const consumedStaked = consumeFromLocation(ledger, 'staked', stakingShares) + const consumedVault = consumeFromLocation(ledger, 'vault', vaultShares) + const consumedLots = [...consumedStaked.consumedLots, ...consumedVault.consumedLots] + const knownLots = consumedLots.filter((lot) => lot.costBasis !== null) + const unknownLots = consumedLots.filter((lot) => lot.costBasis === null) + const knownShares = sumShares(knownLots) + const knownCostBasis = sumKnownCostBasis(knownLots) + const knownProceeds = totalShares === ZERO ? ZERO : (totalAssets * knownShares) / totalShares + const unknownShares = consumedStaked.consumedShares + consumedVault.consumedShares - knownShares + const unknownProceeds = totalShares === ZERO ? ZERO : (totalAssets * unknownShares) / totalShares + + if (knownShares > ZERO) { + ledger.realizedEntries.push({ + timestamp, + proceedsAssets: knownProceeds, + basisAssets: knownCostBasis, + pnlAssets: knownProceeds - knownCostBasis, + consumedLots: knownLots + }) + } + + if (unknownShares > ZERO) { + ledger.withdrawalsWithUnknownCostBasis += 1 + ledger.unknownWithdrawalEntries.push({ + timestamp, + shares: unknownShares, + proceedsAssets: unknownProceeds, + consumedLots: unknownLots + }) + } + + if (consumedStaked.consumedShares + consumedVault.consumedShares < totalShares) { + ledger.unmatchedTransferOutCount += 1 + ledger.unmatchedTransferOutShares += totalShares - (consumedStaked.consumedShares + consumedVault.consumedShares) + } + + return { + realizedPnlAssets: knownShares > ZERO ? knownProceeds - knownCostBasis : ZERO, + knownShares, + unknownShares, + knownProceedsAssets: knownProceeds, + knownCostBasisAssets: knownCostBasis, + consumedVaultShares: consumedVault.consumedShares, + consumedStakedShares: consumedStaked.consumedShares + } +} + +function buildJournalView(parts: { + acquiredToVault: bigint + acquiredToStaked: bigint + vaultRealizeShares: bigint + stakingRealizeShares: bigint + stakeShares: bigint + unstakeShares: bigint + rewardInVaultShares: bigint + rewardInStakedShares: bigint + unknownInVaultShares: bigint + unknownInStakedShares: bigint + transferOutVaultShares: bigint + transferOutStakedShares: bigint +}): string { + const labels: string[] = [] + + if (parts.acquiredToVault > ZERO) labels.push('deposit->vault') + if (parts.acquiredToStaked > ZERO) labels.push('deposit->staked') + if (parts.vaultRealizeShares > ZERO) labels.push('withdraw<-vault') + if (parts.stakingRealizeShares > ZERO) labels.push('withdraw<-staked') + if (parts.stakeShares > ZERO) labels.push('stake') + if (parts.unstakeShares > ZERO) labels.push('unstake') + if (parts.rewardInVaultShares > ZERO) labels.push('reward_in_vault') + if (parts.rewardInStakedShares > ZERO) labels.push('reward_in_staked') + if (parts.unknownInVaultShares > ZERO) labels.push('transfer_in_vault_unknown') + if (parts.unknownInStakedShares > ZERO) labels.push('transfer_in_staked_unknown') + if (parts.transferOutVaultShares > ZERO) labels.push('transfer_out_vault') + if (parts.transferOutStakedShares > ZERO) labels.push('transfer_out_staked') + + return labels.length > 0 ? labels.join(' + ') : 'no_position_change' +} + +function addAcquisitionLots( + ledger: FamilyPnlLedger, + totalShares: bigint, + totalAssets: bigint, + stakedShares: bigint, + vaultShares: bigint, + timestamp: number +): void { + if (totalShares === ZERO) { + return + } + + const stakedAssets = (totalAssets * stakedShares) / totalShares + const vaultAssets = (totalAssets * vaultShares) / totalShares + + addLotsToLocation(ledger, 'staked', [createLot(stakedShares, stakedAssets, timestamp)]) + addLotsToLocation(ledger, 'vault', [createLot(vaultShares, vaultAssets, timestamp)]) +} + +function redistributeLotsToTargetShares(lots: TLot[], targetShares: bigint): TLot[] { + const totalShares = sumShares(lots) + + if (totalShares === ZERO || targetShares === ZERO) { + return [] + } + + let allocatedShares = ZERO + + return lots.reduce((redistributed, lot, index) => { + const shares = + index === lots.length - 1 ? targetShares - allocatedShares : (targetShares * lot.shares) / totalShares + + allocatedShares += shares + + if (shares > ZERO) { + redistributed.push(createLot(shares, lot.costBasis, lot.acquiredAt)) + } + + return redistributed + }, []) +} + +function handleUnknownTransferIn( + ledger: FamilyPnlLedger, + location: TLocation, + shares: bigint, + timestamp: number +): void { + if (shares === ZERO) { + return + } + + ledger.eventCounts.externalTransfersIn += 1 + ledger.unknownCostBasisTransferInCount += 1 + ledger.unknownCostBasisTransferInShares += shares + ledger.unknownTransferInEntries.push({ + timestamp, + shares, + location + }) + addLotsToLocation(ledger, location, [createLot(shares, null, timestamp)]) +} + +function handleRewardTransferIn( + ledger: FamilyPnlLedger, + location: TLocation, + shares: bigint, + timestamp: number, + distributor: string +): void { + if (shares === ZERO) { + return + } + + ledger.eventCounts.rewardTransfersIn += 1 + ledger.rewardTransferInEntries.push({ + timestamp, + shares, + location, + distributor + }) + addLotsToLocation(ledger, location, [createLot(shares, ZERO, timestamp)]) +} + +function getRecognizedRewardTransferIns( + txFamilyEvents: TRawPnlEvent[], + userAddress: string, + familyVaultAddress: string, + stakingVaultAddress: string | null +): FamilyPnlLedger['rewardTransferInEntries'] { + return txFamilyEvents.flatMap((event) => { + if ( + event.kind !== 'transfer' || + !event.scopes.address || + event.receiver !== userAddress || + !isKnownZeroBasisRewardDistribution(event.chainId, event.sender, event.vaultAddress) + ) { + return [] + } + + if (event.vaultAddress === familyVaultAddress) { + return [ + { + timestamp: event.blockTimestamp, + shares: event.shares, + location: 'vault' as const, + distributor: event.sender + } + ] + } + + if (stakingVaultAddress !== null && event.vaultAddress === stakingVaultAddress) { + return [ + { + timestamp: event.blockTimestamp, + shares: event.shares, + location: 'staked' as const, + distributor: event.sender + } + ] + } + + return [] + }) +} + +function allocateRecognizedRewardTransferIns( + rewardEntries: FamilyPnlLedger['rewardTransferInEntries'], + availableVaultShares: bigint, + availableStakedShares: bigint +): { + allocatedEntries: FamilyPnlLedger['rewardTransferInEntries'] + rewardInVaultShares: bigint + rewardInStakedShares: bigint +} { + let remainingVaultShares = availableVaultShares + let remainingStakedShares = availableStakedShares + const allocatedEntries: FamilyPnlLedger['rewardTransferInEntries'] = [] + + rewardEntries.forEach((entry) => { + const availableShares = entry.location === 'vault' ? remainingVaultShares : remainingStakedShares + const allocatedShares = minBigInt(entry.shares, availableShares) + + if (allocatedShares === ZERO) { + return + } + + allocatedEntries.push({ + ...entry, + shares: allocatedShares + }) + + if (entry.location === 'vault') { + remainingVaultShares -= allocatedShares + return + } + + remainingStakedShares -= allocatedShares + }) + + return { + allocatedEntries, + rewardInVaultShares: availableVaultShares - remainingVaultShares, + rewardInStakedShares: availableStakedShares - remainingStakedShares + } +} + +function handleExternalTransferOut(ledger: FamilyPnlLedger, location: TLocation, shares: bigint): void { + if (shares === ZERO) { + return + } + + ledger.eventCounts.externalTransfersOut += 1 + + const consumed = consumeFromLocation(ledger, location, shares) + + if (consumed.consumedShares < shares) { + ledger.unmatchedTransferOutCount += 1 + ledger.unmatchedTransferOutShares += shares - consumed.consumedShares + } +} + +function detectKnownMigratorCrossFamilyRollover( + txEvents: TRawPnlEvent[], + userAddress: string +): TDetectedKnownMigration | null { + const txFamilyGroups = groupTransactionEventsByFamily(txEvents) + const sourceFamilyCandidates = txFamilyGroups.filter((familyEvents) => { + const addressTransfersToMigrator = familyEvents.filter( + (event): event is Extract => + event.kind === 'transfer' && + event.scopes.address && + event.sender === userAddress && + KNOWN_VAULT_ROLLOVER_INTERMEDIARIES.has(event.receiver) + ) + + if (addressTransfersToMigrator.length === 0) { + return false + } + + if ( + familyEvents.some((event) => event.scopes.address && (event.kind === 'deposit' || event.kind === 'withdrawal')) + ) { + return false + } + + const migratorAddress = addressTransfersToMigrator[0]?.receiver ?? null + + if (migratorAddress === null || addressTransfersToMigrator.some((event) => event.receiver !== migratorAddress)) { + return false + } + + const burnedShares = sumEventShares( + familyEvents.filter( + (event): event is Extract => + event.kind === 'transfer' && event.sender === migratorAddress && event.receiver === ZERO_ADDRESS + ) + ) + + return burnedShares > ZERO && burnedShares === sumEventShares(addressTransfersToMigrator) + }) + const destinationFamilyCandidates = txFamilyGroups.filter((familyEvents) => { + const addressDeposits = familyEvents.filter( + (event): event is Extract => + event.kind === 'deposit' && event.scopes.address && event.owner === userAddress + ) + + if (addressDeposits.length === 0) { + return false + } + + const mintedShares = sumEventShares( + familyEvents.filter( + (event): event is Extract => + event.kind === 'transfer' && + event.scopes.address && + event.sender === ZERO_ADDRESS && + event.receiver === userAddress + ) + ) + + return mintedShares > ZERO + }) + const sourceFamilyEvents = sourceFamilyCandidates[0] + const destinationFamilyEvents = destinationFamilyCandidates[0] + + if ( + sourceFamilyCandidates.length !== 1 || + destinationFamilyCandidates.length !== 1 || + !sourceFamilyEvents || + !destinationFamilyEvents + ) { + return null + } + + const sourceFamilyVaultAddress = sourceFamilyEvents[0]?.familyVaultAddress + const destinationFamilyVaultAddress = destinationFamilyEvents[0]?.familyVaultAddress + const chainId = sourceFamilyEvents[0]?.chainId + + if ( + !sourceFamilyVaultAddress || + !destinationFamilyVaultAddress || + chainId === undefined || + destinationFamilyEvents[0]?.chainId !== chainId || + sourceFamilyVaultAddress === destinationFamilyVaultAddress + ) { + return null + } + + const sourceTransfersToMigrator = sourceFamilyEvents.filter( + (event): event is Extract => + event.kind === 'transfer' && + event.scopes.address && + event.sender === userAddress && + KNOWN_VAULT_ROLLOVER_INTERMEDIARIES.has(event.receiver) + ) + const destinationDeposits = destinationFamilyEvents.filter( + (event): event is Extract => + event.kind === 'deposit' && event.scopes.address && event.owner === userAddress + ) + const destinationMintsToUser = destinationFamilyEvents.filter( + (event): event is Extract => + event.kind === 'transfer' && + event.scopes.address && + event.sender === ZERO_ADDRESS && + event.receiver === userAddress + ) + const sourceTransferShares = sumEventShares(sourceTransfersToMigrator) + const destinationMintedShares = sumEventShares(destinationMintsToUser) + const destinationDepositShares = + destinationMintedShares > ZERO ? destinationMintedShares : sumEventShares(destinationDeposits) + const destinationDepositAssets = sumEventAssets(destinationDeposits) + + if (sourceTransferShares === ZERO || destinationDepositShares === ZERO || destinationDepositAssets === ZERO) { + return null + } + + return { + chainId, + sourceFamilyVaultAddress, + destinationFamilyVaultAddress, + transactionHash: sourceFamilyEvents[0]?.transactionHash ?? destinationFamilyEvents[0]?.transactionHash ?? '', + blockTimestamp: sourceFamilyEvents[0]?.blockTimestamp ?? destinationFamilyEvents[0]?.blockTimestamp ?? 0, + sourceTransferShares, + destinationDepositShares, + destinationDepositAssets, + sourceUnderlyingWithdrawCount: 0, + sourceUnderlyingWithdrawAssets: ZERO, + destinationUnderlyingDepositCount: 0, + destinationUnderlyingDepositAssets: ZERO + } +} + +function detectKnownCompatibleAssetVaultCrossFamilyRollover( + txEvents: TRawPnlEvent[], + userAddress: string, + vaultMetadata: Map +): TDetectedKnownMigration | null { + const txFamilyGroups = groupTransactionEventsByFamily(txEvents) + const candidates = txFamilyGroups.flatMap((outerFamilyEvents) => { + const outerFamilyVaultAddress = outerFamilyEvents[0]?.familyVaultAddress + const chainId = outerFamilyEvents[0]?.chainId + + if (!outerFamilyVaultAddress || chainId === undefined) { + return [] + } + + const outerMetadata = vaultMetadata.get(toVaultKey(chainId, outerFamilyVaultAddress)) + + if (!outerMetadata) { + return [] + } + + const innerFamilyVaultAddress = outerMetadata.token.address.toLowerCase() + + if (!isKnownCompatibleAssetVaultRollover(chainId, outerFamilyVaultAddress, innerFamilyVaultAddress)) { + return [] + } + + const innerFamilyEvents = txFamilyGroups.find( + (familyEvents) => + familyEvents[0]?.chainId === chainId && familyEvents[0]?.familyVaultAddress === innerFamilyVaultAddress + ) + + if (!innerFamilyEvents) { + return [] + } + + const destinationDeposits = outerFamilyEvents.filter( + (event): event is Extract => + event.kind === 'deposit' && event.scopes.address && event.owner === userAddress + ) + const destinationMintsToUser = outerFamilyEvents.filter( + (event): event is Extract => + event.kind === 'transfer' && + event.scopes.address && + event.sender === ZERO_ADDRESS && + event.receiver === userAddress + ) + const sourceTransfersToOuterVault = innerFamilyEvents.filter( + (event): event is Extract => + event.kind === 'transfer' && + event.scopes.address && + event.sender === userAddress && + event.receiver === outerFamilyVaultAddress + ) + const sourceTransferShares = sumEventShares(sourceTransfersToOuterVault) + const destinationDepositAssets = sumEventAssets(destinationDeposits) + const destinationMintedShares = sumEventShares(destinationMintsToUser) + const destinationDepositShares = + destinationMintedShares > ZERO ? destinationMintedShares : sumEventShares(destinationDeposits) + + if ( + sourceTransferShares > ZERO && + destinationDepositShares > ZERO && + destinationDepositAssets > ZERO && + destinationDepositAssets === sourceTransferShares + ) { + return [ + { + chainId, + sourceFamilyVaultAddress: innerFamilyVaultAddress, + destinationFamilyVaultAddress: outerFamilyVaultAddress, + transactionHash: innerFamilyEvents[0]?.transactionHash ?? outerFamilyEvents[0]?.transactionHash ?? '', + blockTimestamp: innerFamilyEvents[0]?.blockTimestamp ?? outerFamilyEvents[0]?.blockTimestamp ?? 0, + sourceTransferShares, + destinationDepositShares, + destinationDepositAssets, + sourceUnderlyingWithdrawCount: 0, + sourceUnderlyingWithdrawAssets: ZERO, + destinationUnderlyingDepositCount: destinationDeposits.length, + destinationUnderlyingDepositAssets: destinationDepositAssets + } + ] + } + + const sourceWithdrawals = outerFamilyEvents.filter( + (event): event is Extract => + event.kind === 'withdrawal' && event.scopes.address && event.owner === userAddress + ) + const sourceBurnedShares = sumEventShares( + outerFamilyEvents.filter( + (event): event is Extract => + event.kind === 'transfer' && + event.scopes.address && + event.sender === userAddress && + event.receiver === ZERO_ADDRESS + ) + ) + const destinationTransfersFromOuterVault = innerFamilyEvents.filter( + (event): event is Extract => + event.kind === 'transfer' && + event.scopes.address && + event.sender === outerFamilyVaultAddress && + event.receiver === userAddress + ) + const sourceWithdrawShares = sumEventShares(sourceWithdrawals) + const sourceWithdrawAssets = sumEventAssets(sourceWithdrawals) + const destinationTransferShares = sumEventShares(destinationTransfersFromOuterVault) + + if ( + sourceWithdrawShares > ZERO && + sourceWithdrawAssets > ZERO && + destinationTransferShares > ZERO && + sourceWithdrawAssets === destinationTransferShares && + (sourceBurnedShares === ZERO || sourceBurnedShares === sourceWithdrawShares) + ) { + return [ + { + chainId, + sourceFamilyVaultAddress: outerFamilyVaultAddress, + destinationFamilyVaultAddress: innerFamilyVaultAddress, + transactionHash: outerFamilyEvents[0]?.transactionHash ?? innerFamilyEvents[0]?.transactionHash ?? '', + blockTimestamp: outerFamilyEvents[0]?.blockTimestamp ?? innerFamilyEvents[0]?.blockTimestamp ?? 0, + sourceTransferShares: sourceWithdrawShares, + destinationDepositShares: destinationTransferShares, + destinationDepositAssets: destinationTransferShares, + sourceUnderlyingWithdrawCount: sourceWithdrawals.length, + sourceUnderlyingWithdrawAssets: sourceWithdrawAssets, + destinationUnderlyingDepositCount: 0, + destinationUnderlyingDepositAssets: ZERO + } + ] + } + + return [] + }) + + return candidates.length === 1 ? candidates[0] : null +} + +function detectKnownCrossFamilyRollover( + txEvents: TRawPnlEvent[], + userAddress: string, + vaultMetadata: Map +): TDetectedKnownMigration | null { + return ( + detectKnownMigratorCrossFamilyRollover(txEvents, userAddress) ?? + detectKnownCompatibleAssetVaultCrossFamilyRollover(txEvents, userAddress, vaultMetadata) + ) +} + +function processKnownCrossFamilyRollover( + ledgers: Map, + txEvents: TRawPnlEvent[], + userAddress: string, + vaultMetadata: Map +): Set { + const migration = detectKnownCrossFamilyRollover(txEvents, userAddress, vaultMetadata) + + if (!migration) { + return new Set() + } + + const sourceLedger = getOrCreateLedger(ledgers, migration.chainId, migration.sourceFamilyVaultAddress) + const destinationLedger = getOrCreateLedger(ledgers, migration.chainId, migration.destinationFamilyVaultAddress) + const sourceFamilyEvents = txEvents.filter((event) => event.familyVaultAddress === migration.sourceFamilyVaultAddress) + const destinationFamilyEvents = txEvents.filter( + (event) => event.familyVaultAddress === migration.destinationFamilyVaultAddress + ) + const sourceShouldLog = shouldLogLotTransactions(sourceLedger, migration.transactionHash) + const destinationShouldLog = shouldLogLotTransactions(destinationLedger, migration.transactionHash) + const includeDetailedLotLogs = shouldIncludeDetailedLotLogs() + const sourceVaultLotsBefore = sourceShouldLog ? cloneLots(sourceLedger.vaultLots) : [] + const sourceStakedLotsBefore = sourceShouldLog ? cloneLots(sourceLedger.stakedLots) : [] + const destinationVaultLotsBefore = destinationShouldLog ? cloneLots(destinationLedger.vaultLots) : [] + const destinationStakedLotsBefore = destinationShouldLog ? cloneLots(destinationLedger.stakedLots) : [] + + sourceLedger.eventCounts.migrationsOut += 1 + destinationLedger.eventCounts.migrationsIn += 1 + sourceLedger.totalWithdrawnAssets += migration.sourceUnderlyingWithdrawAssets + sourceLedger.eventCounts.underlyingWithdrawals += migration.sourceUnderlyingWithdrawCount + destinationLedger.totalDepositedAssets += migration.destinationUnderlyingDepositAssets + destinationLedger.eventCounts.underlyingDeposits += migration.destinationUnderlyingDepositCount + + const sourceConsumed = consumeFromLocation(sourceLedger, 'vault', migration.sourceTransferShares) + const knownSourceLots = sourceConsumed.consumedLots.filter((lot) => lot.costBasis !== null) + const knownSourceShares = sumShares(knownSourceLots) + const knownDestinationShares = + migration.sourceTransferShares === ZERO + ? ZERO + : (migration.destinationDepositShares * knownSourceShares) / migration.sourceTransferShares + const rolledLots = redistributeLotsToTargetShares(knownSourceLots, knownDestinationShares) + const rolledShares = sumShares(rolledLots) + const unknownDestinationShares = migration.destinationDepositShares - rolledShares + const unmatchedSourceShares = migration.sourceTransferShares - sourceConsumed.consumedShares + + addLotsToLocation(destinationLedger, 'vault', rolledLots) + + if (unknownDestinationShares > ZERO) { + destinationLedger.unknownCostBasisTransferInCount += 1 + destinationLedger.unknownCostBasisTransferInShares += unknownDestinationShares + destinationLedger.unknownTransferInEntries.push({ + timestamp: migration.blockTimestamp, + shares: unknownDestinationShares, + location: 'vault' + }) + addLotsToLocation(destinationLedger, 'vault', [createLot(unknownDestinationShares, null, migration.blockTimestamp)]) + } + + if (unmatchedSourceShares > ZERO) { + sourceLedger.unmatchedTransferOutCount += 1 + sourceLedger.unmatchedTransferOutShares += unmatchedSourceShares + } + + sourceLedger.debugJournal.push({ + timestamp: migration.blockTimestamp, + txHash: migration.transactionHash, + familyVaultAddress: migration.sourceFamilyVaultAddress, + stakingVaultAddress: sourceLedger.stakingVaultAddress, + view: 'migrate_out', + hasAddressActivity: true, + rawEvents: countTxEventsByKind(sourceFamilyEvents), + depositShares: ZERO.toString(), + depositAssets: ZERO.toString(), + withdrawShares: ZERO.toString(), + withdrawAssets: ZERO.toString(), + stakeShares: ZERO.toString(), + unstakeShares: ZERO.toString(), + rewardInVaultShares: ZERO.toString(), + rewardInStakedShares: ZERO.toString(), + unknownInVaultShares: ZERO.toString(), + unknownInStakedShares: ZERO.toString(), + transferOutVaultShares: migration.sourceTransferShares.toString(), + transferOutStakedShares: ZERO.toString(), + realizedKnownShares: ZERO.toString(), + realizedProceedsAssets: ZERO.toString(), + realizedBasisAssets: ZERO.toString(), + realizedPnlAssets: ZERO.toString(), + vaultLotsBefore: summarizeLots(sourceVaultLotsBefore), + stakedLotsBefore: summarizeLots(sourceStakedLotsBefore), + vaultLotsAfter: summarizeLots(sourceLedger.vaultLots), + stakedLotsAfter: summarizeLots(sourceLedger.stakedLots) + }) + + destinationLedger.debugJournal.push({ + timestamp: migration.blockTimestamp, + txHash: migration.transactionHash, + familyVaultAddress: migration.destinationFamilyVaultAddress, + stakingVaultAddress: destinationLedger.stakingVaultAddress, + view: 'migrate_in->vault', + hasAddressActivity: true, + rawEvents: countTxEventsByKind(destinationFamilyEvents), + depositShares: migration.destinationDepositShares.toString(), + depositAssets: migration.destinationDepositAssets.toString(), + withdrawShares: ZERO.toString(), + withdrawAssets: ZERO.toString(), + stakeShares: ZERO.toString(), + unstakeShares: ZERO.toString(), + rewardInVaultShares: ZERO.toString(), + rewardInStakedShares: ZERO.toString(), + unknownInVaultShares: unknownDestinationShares.toString(), + unknownInStakedShares: ZERO.toString(), + transferOutVaultShares: ZERO.toString(), + transferOutStakedShares: ZERO.toString(), + realizedKnownShares: ZERO.toString(), + realizedProceedsAssets: ZERO.toString(), + realizedBasisAssets: ZERO.toString(), + realizedPnlAssets: ZERO.toString(), + vaultLotsBefore: summarizeLots(destinationVaultLotsBefore), + stakedLotsBefore: summarizeLots(destinationStakedLotsBefore), + vaultLotsAfter: summarizeLots(destinationLedger.vaultLots), + stakedLotsAfter: summarizeLots(destinationLedger.stakedLots) + }) + + if (sourceShouldLog) { + debugLog('pnl-lots', 'processed family transaction', { + chainId: migration.chainId, + familyVaultAddress: migration.sourceFamilyVaultAddress, + stakingVaultAddress: sourceLedger.stakingVaultAddress, + transactionHash: migration.transactionHash, + blockTimestamp: migration.blockTimestamp, + view: 'migrate_out', + sourceTransferShares: migration.sourceTransferShares.toString(), + knownRolledSourceShares: knownSourceShares.toString(), + unmatchedSourceShares: unmatchedSourceShares.toString(), + vaultLotsBefore: summarizeLots(sourceVaultLotsBefore), + stakedLotsBefore: summarizeLots(sourceStakedLotsBefore), + vaultLotsAfter: summarizeLots(sourceLedger.vaultLots), + stakedLotsAfter: summarizeLots(sourceLedger.stakedLots), + events: includeDetailedLotLogs ? sourceFamilyEvents.map(serializeRawEvent) : undefined, + vaultLotEntriesBefore: includeDetailedLotLogs ? serializeLots(sourceVaultLotsBefore) : undefined, + stakedLotEntriesBefore: includeDetailedLotLogs ? serializeLots(sourceStakedLotsBefore) : undefined, + vaultLotEntriesAfter: includeDetailedLotLogs ? serializeLots(sourceLedger.vaultLots) : undefined, + stakedLotEntriesAfter: includeDetailedLotLogs ? serializeLots(sourceLedger.stakedLots) : undefined + }) + } + + if (destinationShouldLog) { + debugLog('pnl-lots', 'processed family transaction', { + chainId: migration.chainId, + familyVaultAddress: migration.destinationFamilyVaultAddress, + stakingVaultAddress: destinationLedger.stakingVaultAddress, + transactionHash: migration.transactionHash, + blockTimestamp: migration.blockTimestamp, + view: 'migrate_in->vault', + sourceFamilyVaultAddress: migration.sourceFamilyVaultAddress, + destinationDepositShares: migration.destinationDepositShares.toString(), + destinationDepositAssets: migration.destinationDepositAssets.toString(), + rolledKnownShares: rolledShares.toString(), + rolledKnownCostBasisAssets: sumKnownCostBasis(rolledLots).toString(), + unknownDestinationShares: unknownDestinationShares.toString(), + vaultLotsBefore: summarizeLots(destinationVaultLotsBefore), + stakedLotsBefore: summarizeLots(destinationStakedLotsBefore), + vaultLotsAfter: summarizeLots(destinationLedger.vaultLots), + stakedLotsAfter: summarizeLots(destinationLedger.stakedLots), + events: includeDetailedLotLogs ? destinationFamilyEvents.map(serializeRawEvent) : undefined, + vaultLotEntriesBefore: includeDetailedLotLogs ? serializeLots(destinationVaultLotsBefore) : undefined, + stakedLotEntriesBefore: includeDetailedLotLogs ? serializeLots(destinationStakedLotsBefore) : undefined, + vaultLotEntriesAfter: includeDetailedLotLogs ? serializeLots(destinationLedger.vaultLots) : undefined, + stakedLotEntriesAfter: includeDetailedLotLogs ? serializeLots(destinationLedger.stakedLots) : undefined + }) + } + + return new Set([ + toVaultKey(migration.chainId, migration.sourceFamilyVaultAddress), + toVaultKey(migration.chainId, migration.destinationFamilyVaultAddress) + ]) +} + +function isSameVaultRolloverTransaction(parts: { + txFamilyEvents: TRawPnlEvent[] + txUnderlyingDeposits: Array> + txUnderlyingWithdrawals: Array> + familyVaultAddress: string + totalUnderlyingDepositAssets: bigint + totalUnderlyingWithdrawAssets: bigint + stakingDelta: bigint +}): boolean { + return ( + parts.txFamilyEvents.length >= 4 && + parts.txFamilyEvents.every((event) => event.vaultAddress === parts.familyVaultAddress) && + parts.txUnderlyingDeposits.length === 1 && + parts.txUnderlyingWithdrawals.length === 1 && + parts.totalUnderlyingDepositAssets > ZERO && + parts.totalUnderlyingDepositAssets === parts.totalUnderlyingWithdrawAssets && + parts.stakingDelta === ZERO + ) +} + +function processFamilyTransaction(ledger: FamilyPnlLedger, txFamilyEvents: TRawPnlEvent[], userAddress: string): void { + const familyVaultAddress = ledger.vaultAddress + const stakingVaultAddress = ledger.stakingVaultAddress + const transactionHash = txFamilyEvents[0]?.transactionHash ?? '' + const shouldLogTransactionLots = shouldLogLotTransactions(ledger, transactionHash) + const includeDetailedLotLogs = shouldIncludeDetailedLotLogs() + const vaultLotsBefore = shouldLogTransactionLots ? cloneLots(ledger.vaultLots) : [] + const stakedLotsBefore = shouldLogTransactionLots ? cloneLots(ledger.stakedLots) : [] + const familyHasAddressActivity = hasAddressScopedFamilyActivity( + txFamilyEvents, + familyVaultAddress, + stakingVaultAddress + ) + const txUnderlyingDeposits = txFamilyEvents.filter( + (event): event is Extract => + event.kind === 'deposit' && + event.vaultAddress === familyVaultAddress && + shouldIncludeFamilyVaultAssetEvent(event, familyHasAddressActivity) + ) + const txUnderlyingWithdrawals = txFamilyEvents.filter( + (event): event is Extract => + event.kind === 'withdrawal' && + event.vaultAddress === familyVaultAddress && + shouldIncludeFamilyVaultAssetEvent(event, familyHasAddressActivity) + ) + const underlyingDelta = calculateUserContractDelta(txFamilyEvents, userAddress, familyVaultAddress) + const stakingDelta = + stakingVaultAddress === null ? ZERO : calculateUserContractDelta(txFamilyEvents, userAddress, stakingVaultAddress) + const totalUnderlyingDepositShares = sumEventShares(txUnderlyingDeposits) + const totalUnderlyingDepositAssets = sumEventAssets(txUnderlyingDeposits) + const totalUnderlyingWithdrawShares = sumEventShares(txUnderlyingWithdrawals) + const totalUnderlyingWithdrawAssets = sumEventAssets(txUnderlyingWithdrawals) + + if ( + isSameVaultRolloverTransaction({ + txFamilyEvents, + txUnderlyingDeposits, + txUnderlyingWithdrawals, + familyVaultAddress, + totalUnderlyingDepositAssets, + totalUnderlyingWithdrawAssets, + stakingDelta + }) + ) { + const vaultConsumed = consumeLots(ledger.vaultLots, totalUnderlyingWithdrawShares) + const stakedConsumed = consumeLots(ledger.stakedLots, ZERO) + const consumedShares = vaultConsumed.consumedShares + stakedConsumed.consumedShares + + if (consumedShares === totalUnderlyingWithdrawShares) { + const rolledLots = redistributeLotsToTargetShares(vaultConsumed.consumedLots, totalUnderlyingDepositShares) + + ledger.vaultLots = [...vaultConsumed.nextLots, ...rolledLots] + + ledger.debugJournal.push({ + timestamp: txFamilyEvents[0]?.blockTimestamp ?? 0, + txHash: transactionHash, + familyVaultAddress, + stakingVaultAddress, + view: 'same_vault_rollover->vault', + hasAddressActivity: familyHasAddressActivity, + rawEvents: countTxEventsByKind(txFamilyEvents), + depositShares: totalUnderlyingDepositShares.toString(), + depositAssets: totalUnderlyingDepositAssets.toString(), + withdrawShares: totalUnderlyingWithdrawShares.toString(), + withdrawAssets: totalUnderlyingWithdrawAssets.toString(), + stakeShares: ZERO.toString(), + unstakeShares: ZERO.toString(), + rewardInVaultShares: ZERO.toString(), + rewardInStakedShares: ZERO.toString(), + unknownInVaultShares: ZERO.toString(), + unknownInStakedShares: ZERO.toString(), + transferOutVaultShares: ZERO.toString(), + transferOutStakedShares: ZERO.toString(), + realizedKnownShares: ZERO.toString(), + realizedProceedsAssets: ZERO.toString(), + realizedBasisAssets: ZERO.toString(), + realizedPnlAssets: ZERO.toString(), + vaultLotsBefore: summarizeLots(vaultLotsBefore), + stakedLotsBefore: summarizeLots(stakedLotsBefore), + vaultLotsAfter: summarizeLots(ledger.vaultLots), + stakedLotsAfter: summarizeLots(ledger.stakedLots) + }) + + if (shouldLogTransactionLots) { + debugLog('pnl-lots', 'processed family transaction', { + chainId: ledger.chainId, + familyVaultAddress, + stakingVaultAddress, + transactionHash, + blockTimestamp: txFamilyEvents[0]?.blockTimestamp ?? null, + familyHasAddressActivity, + eventCount: txFamilyEvents.length, + view: 'same_vault_rollover->vault', + totalUnderlyingDepositShares: totalUnderlyingDepositShares.toString(), + totalUnderlyingDepositAssets: totalUnderlyingDepositAssets.toString(), + totalUnderlyingWithdrawShares: totalUnderlyingWithdrawShares.toString(), + totalUnderlyingWithdrawAssets: totalUnderlyingWithdrawAssets.toString(), + rolledLotCount: rolledLots.length, + rolledKnownCostBasisAssets: sumKnownCostBasis(rolledLots.filter((lot) => lot.costBasis !== null)).toString(), + vaultLotsBefore: summarizeLots(vaultLotsBefore), + stakedLotsBefore: summarizeLots(stakedLotsBefore), + vaultLotsAfter: summarizeLots(ledger.vaultLots), + stakedLotsAfter: summarizeLots(ledger.stakedLots), + events: includeDetailedLotLogs ? txFamilyEvents.map(serializeRawEvent) : undefined, + vaultLotEntriesBefore: includeDetailedLotLogs ? serializeLots(vaultLotsBefore) : undefined, + stakedLotEntriesBefore: includeDetailedLotLogs ? serializeLots(stakedLotsBefore) : undefined, + vaultLotEntriesAfter: includeDetailedLotLogs ? serializeLots(ledger.vaultLots) : undefined, + stakedLotEntriesAfter: includeDetailedLotLogs ? serializeLots(ledger.stakedLots) : undefined + }) + } + + return + } + } + + ledger.totalDepositedAssets += totalUnderlyingDepositAssets + ledger.totalWithdrawnAssets += totalUnderlyingWithdrawAssets + ledger.eventCounts.underlyingDeposits += txUnderlyingDeposits.length + ledger.eventCounts.underlyingWithdrawals += txUnderlyingWithdrawals.length + + const stakingRealizeShares = minBigInt(negativeBigIntMagnitude(stakingDelta), totalUnderlyingWithdrawShares) + const vaultRealizeShares = totalUnderlyingWithdrawShares - stakingRealizeShares + + const realization = applyRealization( + ledger, + totalUnderlyingWithdrawShares, + totalUnderlyingWithdrawAssets, + stakingRealizeShares, + vaultRealizeShares, + txFamilyEvents[0]?.blockTimestamp ?? 0 + ) + + const acquiredToStaked = minBigInt(positiveBigInt(stakingDelta), totalUnderlyingDepositShares) + const acquiredToVault = minBigInt(positiveBigInt(underlyingDelta), totalUnderlyingDepositShares - acquiredToStaked) + + addAcquisitionLots( + ledger, + totalUnderlyingDepositShares, + totalUnderlyingDepositAssets, + acquiredToStaked, + acquiredToVault, + txFamilyEvents[0]?.blockTimestamp ?? 0 + ) + + const remainingUnderlyingDelta = underlyingDelta - acquiredToVault + vaultRealizeShares + const remainingStakingDelta = stakingDelta - acquiredToStaked + stakingRealizeShares + const stakeShares = minBigInt( + negativeBigIntMagnitude(remainingUnderlyingDelta), + positiveBigInt(remainingStakingDelta) + ) + const remainingUnderlyingAfterWrap = remainingUnderlyingDelta + stakeShares + const remainingStakingAfterWrap = remainingStakingDelta - stakeShares + const unstakeShares = minBigInt( + negativeBigIntMagnitude(remainingStakingAfterWrap), + positiveBigInt(remainingUnderlyingAfterWrap) + ) + const finalUnderlyingDelta = remainingUnderlyingAfterWrap - unstakeShares + const finalStakingDelta = remainingStakingAfterWrap + unstakeShares + const rewardAllocation = allocateRecognizedRewardTransferIns( + getRecognizedRewardTransferIns(txFamilyEvents, userAddress, familyVaultAddress, stakingVaultAddress), + positiveBigInt(finalUnderlyingDelta), + positiveBigInt(finalStakingDelta) + ) + const rewardInVaultShares = rewardAllocation.rewardInVaultShares + const rewardInStakedShares = rewardAllocation.rewardInStakedShares + const unknownInVaultShares = positiveBigInt(finalUnderlyingDelta) - rewardInVaultShares + const unknownInStakedShares = positiveBigInt(finalStakingDelta) - rewardInStakedShares + const transferOutVaultShares = negativeBigIntMagnitude(finalUnderlyingDelta) + const transferOutStakedShares = negativeBigIntMagnitude(finalStakingDelta) + + if (stakeShares > ZERO) { + ledger.eventCounts.stakes += 1 + moveBetweenLocations(ledger, 'vault', 'staked', stakeShares) + } + + if (unstakeShares > ZERO) { + ledger.eventCounts.unstakes += 1 + moveBetweenLocations(ledger, 'staked', 'vault', unstakeShares) + } + + rewardAllocation.allocatedEntries.forEach((entry) => { + handleRewardTransferIn(ledger, entry.location, entry.shares, entry.timestamp, entry.distributor) + }) + handleUnknownTransferIn(ledger, 'vault', unknownInVaultShares, txFamilyEvents[0]?.blockTimestamp ?? 0) + handleUnknownTransferIn(ledger, 'staked', unknownInStakedShares, txFamilyEvents[0]?.blockTimestamp ?? 0) + handleExternalTransferOut(ledger, 'vault', transferOutVaultShares) + handleExternalTransferOut(ledger, 'staked', transferOutStakedShares) + + ledger.debugJournal.push({ + timestamp: txFamilyEvents[0]?.blockTimestamp ?? 0, + txHash: transactionHash, + familyVaultAddress, + stakingVaultAddress, + view: buildJournalView({ + acquiredToVault, + acquiredToStaked, + vaultRealizeShares, + stakingRealizeShares, + stakeShares, + unstakeShares, + rewardInVaultShares, + rewardInStakedShares, + unknownInVaultShares, + unknownInStakedShares, + transferOutVaultShares, + transferOutStakedShares + }), + hasAddressActivity: familyHasAddressActivity, + rawEvents: countTxEventsByKind(txFamilyEvents), + depositShares: totalUnderlyingDepositShares.toString(), + depositAssets: totalUnderlyingDepositAssets.toString(), + withdrawShares: totalUnderlyingWithdrawShares.toString(), + withdrawAssets: totalUnderlyingWithdrawAssets.toString(), + stakeShares: stakeShares.toString(), + unstakeShares: unstakeShares.toString(), + rewardInVaultShares: rewardInVaultShares.toString(), + rewardInStakedShares: rewardInStakedShares.toString(), + unknownInVaultShares: unknownInVaultShares.toString(), + unknownInStakedShares: unknownInStakedShares.toString(), + transferOutVaultShares: transferOutVaultShares.toString(), + transferOutStakedShares: transferOutStakedShares.toString(), + realizedKnownShares: realization.knownShares.toString(), + realizedProceedsAssets: realization.knownProceedsAssets.toString(), + realizedBasisAssets: realization.knownCostBasisAssets.toString(), + realizedPnlAssets: realization.realizedPnlAssets.toString(), + vaultLotsBefore: summarizeLots(vaultLotsBefore), + stakedLotsBefore: summarizeLots(stakedLotsBefore), + vaultLotsAfter: summarizeLots(ledger.vaultLots), + stakedLotsAfter: summarizeLots(ledger.stakedLots) + }) + + if (shouldLogTransactionLots) { + debugLog('pnl-lots', 'processed family transaction', { + chainId: ledger.chainId, + familyVaultAddress, + stakingVaultAddress, + transactionHash, + blockTimestamp: txFamilyEvents[0]?.blockTimestamp ?? null, + familyHasAddressActivity, + eventCount: txFamilyEvents.length, + underlyingDelta: underlyingDelta.toString(), + stakingDelta: stakingDelta.toString(), + totalUnderlyingDepositShares: totalUnderlyingDepositShares.toString(), + totalUnderlyingDepositAssets: totalUnderlyingDepositAssets.toString(), + totalUnderlyingWithdrawShares: totalUnderlyingWithdrawShares.toString(), + totalUnderlyingWithdrawAssets: totalUnderlyingWithdrawAssets.toString(), + acquiredToVault: acquiredToVault.toString(), + acquiredToStaked: acquiredToStaked.toString(), + vaultRealizeShares: vaultRealizeShares.toString(), + stakingRealizeShares: stakingRealizeShares.toString(), + stakeShares: stakeShares.toString(), + unstakeShares: unstakeShares.toString(), + rewardInVaultShares: rewardInVaultShares.toString(), + rewardInStakedShares: rewardInStakedShares.toString(), + finalUnderlyingDelta: finalUnderlyingDelta.toString(), + finalStakingDelta: finalStakingDelta.toString(), + realizedKnownShares: realization.knownShares.toString(), + realizedProceedsAssets: realization.knownProceedsAssets.toString(), + realizedBasisAssets: realization.knownCostBasisAssets.toString(), + realizedPnlAssets: realization.realizedPnlAssets.toString(), + vaultLotsBefore: summarizeLots(vaultLotsBefore), + stakedLotsBefore: summarizeLots(stakedLotsBefore), + vaultLotsAfter: summarizeLots(ledger.vaultLots), + stakedLotsAfter: summarizeLots(ledger.stakedLots), + events: includeDetailedLotLogs ? txFamilyEvents.map(serializeRawEvent) : undefined, + vaultLotEntriesBefore: includeDetailedLotLogs ? serializeLots(vaultLotsBefore) : undefined, + stakedLotEntriesBefore: includeDetailedLotLogs ? serializeLots(stakedLotsBefore) : undefined, + vaultLotEntriesAfter: includeDetailedLotLogs ? serializeLots(ledger.vaultLots) : undefined, + stakedLotEntriesAfter: includeDetailedLotLogs ? serializeLots(ledger.stakedLots) : undefined + }) + } +} + +export function processRawPnlEvents( + events: TRawPnlEvent[], + userAddress: string, + vaultMetadata: Map = new Map() +): Map { + const ledgers = new Map() + const userAddressLower = userAddress.toLowerCase() + + // Process transactions in two passes: + // 1. detect cross-family migrations that should carry basis across vaults + // 2. interpret the remaining per-family activity as deposits, withdrawals, stakes, unstakes, or transfers + groupEventsByTransaction(events).forEach((txEvents) => { + const handledFamilyKeys = processKnownCrossFamilyRollover(ledgers, txEvents, userAddressLower, vaultMetadata) + + groupTransactionEventsByFamily(txEvents).forEach((txFamilyEvents) => { + const familyVaultAddress = txFamilyEvents[0]?.familyVaultAddress + const chainId = txFamilyEvents[0]?.chainId + + if (!familyVaultAddress || chainId === undefined) { + return + } + + if (handledFamilyKeys.has(toVaultKey(chainId, familyVaultAddress))) { + return + } + + const ledger = getOrCreateLedger(ledgers, chainId, familyVaultAddress) + processFamilyTransaction(ledger, txFamilyEvents, userAddressLower) + }) + }) + + return Array.from(ledgers.entries()).reduce>((filtered, [key, ledger]) => { + if (!isLedgerEmpty(ledger)) { + filtered.set(key, ledger) + } + + return filtered + }, new Map()) +} + +async function computeHoldingsPnLArtifacts( + userAddress: string, + version: VaultVersion, + unknownTransferInPnlMode: UnknownTransferInPnlMode, + fetchType: HoldingsEventFetchType, + paginationMode: HoldingsEventPaginationMode +): Promise { + debugLog('pnl', 'starting holdings pnl calculation', { version, unknownTransferInPnlMode, fetchType, paginationMode }) + const rawContext = await fetchRawUserPnlEvents(userAddress, 'all', undefined, fetchType, paginationMode) + debugLog('pnl', 'loaded raw pnl event context', { + addressDeposits: rawContext.addressEvents.deposits.length, + addressWithdrawals: rawContext.addressEvents.withdrawals.length, + addressTransfersIn: rawContext.addressEvents.transfersIn.length, + addressTransfersOut: rawContext.addressEvents.transfersOut.length, + txDeposits: rawContext.transactionEvents.deposits.length, + txWithdrawals: rawContext.transactionEvents.withdrawals.length, + txTransfers: rawContext.transactionEvents.transfers.length + }) + const rawEvents = await enrichRawPnlEventsWithCowTradeAcquisitions(buildRawPnlEvents(rawContext), userAddress) + const rawVaultIdentifiers = Array.from( + rawEvents.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()) + ).map(([, identifier]) => identifier) + const resolvedVaultMetadata = await fetchMultipleVaultsMetadata(rawVaultIdentifiers) + const debugTxLedgerKeys = getDebugTxLedgerKeys(rawEvents) + const rawLedgers = processRawPnlEvents(rawEvents, userAddress, resolvedVaultMetadata) + const directInteractionLedgers = filterDirectInteractionLedgers(rawLedgers) + const ledgers = filterRelevantHoldingsLedgers(rawLedgers) + const vaults = Array.from(ledgers.values()) + const currentTimestamp = Math.floor(Date.now() / 1000) + debugLog('pnl', 'processed raw pnl events into ledgers', { + rawLedgers: rawLedgers.size, + directInteractionLedgers: directInteractionLedgers.size, + relevantHoldingsLedgers: ledgers.size, + currentTimestamp + }) + + if (vaults.length === 0) { + debugLog('pnl', 'no pnl ledgers produced for address') + return { + userAddress, + version, + unknownTransferInPnlMode, + currentTimestamp, + vaults: [], + vaultMetadata: new Map(), + ppsData: new Map>(), + priceData: new Map>(), + debugTxLedgerKeys + } + } + + const filteredVaults = filterVaultsByAuthoritativeVersion(vaults, resolvedVaultMetadata, version) + const filteredVaultIdentifiers = filteredVaults.map((vault) => ({ + chainId: vault.chainId, + vaultAddress: vault.vaultAddress + })) + const baseVaultMetadata = filteredVaultIdentifiers.reduce>((filtered, vault) => { + const key = toVaultKey(vault.chainId, vault.vaultAddress) + const metadata = resolvedVaultMetadata.get(key) + if (metadata) { + filtered.set(key, metadata) + } + return filtered + }, new Map()) + const vaultMetadata = await resolveNestedVaultAssetMetadata(baseVaultMetadata) + + if (version !== 'all') { + debugLog('pnl', 'filtered pnl ledgers by authoritative vault version', { + requestedVersion: version, + ledgersBefore: vaults.length, + ledgersAfter: filteredVaults.length, + metadataResolved: resolvedVaultMetadata.size + }) + } + + if (filteredVaults.length === 0) { + debugLog('pnl', 'no pnl ledgers matched requested version after authoritative vault filtering', { + requestedVersion: version, + ledgersBefore: vaults.length + }) + return { + userAddress, + version, + unknownTransferInPnlMode, + currentTimestamp, + vaults: [], + vaultMetadata, + ppsData: new Map>(), + priceData: new Map>(), + debugTxLedgerKeys + } + } + + const historicalPriceLedgers = filteredVaults.filter((vault) => !isCurrentTransferOnlyHoldingsLedger(vault)) + const timestamps = [ + ...new Set([ + currentTimestamp, + ...historicalPriceLedgers.flatMap((vault) => vault.realizedEntries.map((entry) => entry.timestamp)), + ...historicalPriceLedgers.flatMap((vault) => + [...vault.vaultLots, ...vault.stakedLots] + .map((lot) => lot.acquiredAt) + .filter((timestamp): timestamp is number => timestamp !== undefined) + ), + ...historicalPriceLedgers.flatMap((vault) => + vault.realizedEntries.flatMap((entry) => + entry.consumedLots + .map((lot) => lot.acquiredAt) + .filter((timestamp): timestamp is number => timestamp !== undefined) + ) + ), + ...historicalPriceLedgers.flatMap((vault) => vault.unknownTransferInEntries.map((entry) => entry.timestamp)), + ...historicalPriceLedgers.flatMap((vault) => vault.unknownWithdrawalEntries.map((entry) => entry.timestamp)), + ...historicalPriceLedgers.flatMap((vault) => + vault.unknownWithdrawalEntries.flatMap((entry) => + entry.consumedLots + .map((lot) => lot.acquiredAt) + .filter((timestamp): timestamp is number => timestamp !== undefined) + ) + ) + ]) + ].sort((a, b) => a - b) + const seenTokens = new Set() + const basePriceRequests = filteredVaultIdentifiers.reduce((allTokens, vault) => { + const metadata = vaultMetadata.get(toVaultKey(vault.chainId, vault.vaultAddress)) + + if (!metadata) { + return allTokens + } + + const tokenKey = `${metadata.chainId}:${metadata.token.address.toLowerCase()}` + + if (seenTokens.has(tokenKey)) { + return allTokens + } + + seenTokens.add(tokenKey) + allTokens.push({ + chainId: metadata.chainId, + address: metadata.token.address, + timestamps, + uncachedTimestamps: [currentTimestamp] + }) + return allTokens + }, []) + const priceRequests = expandNestedVaultAssetPriceRequests(basePriceRequests, vaultMetadata) + const ppsIdentifiers = mergeVaultIdentifiers([ + ...filteredVaultIdentifiers, + ...getNestedVaultPpsIdentifiersFromPriceRequests(basePriceRequests, vaultMetadata) + ]) + const [ppsData, fetchedPriceData] = await Promise.all([ + fetchMultipleVaultsPPS(ppsIdentifiers), + fetchHistoricalPricesForTokenTimestamps(priceRequests) + ]) + const priceData = deriveNestedVaultAssetPriceData({ + priceData: fetchedPriceData, + priceRequests, + vaultMetadata, + ppsData + }) + debugLog('pnl', 'resolved vault metadata and PPS', { + vaults: ppsIdentifiers.length, + metadataResolved: vaultMetadata.size, + ppsResolved: ppsData.size, + emptyPpsTimelines: Array.from(ppsData.values()).filter((timeline) => timeline.size === 0).length + }) + debugLog('pnl', 'resolved token prices for pnl', { + tokens: priceRequests.length, + timestamps: timestamps.length, + priceKeys: priceData.size + }) + + return { + userAddress, + version, + unknownTransferInPnlMode, + currentTimestamp, + vaults: filteredVaults, + vaultMetadata, + ppsData, + priceData, + debugTxLedgerKeys + } +} + +function materializeHoldingsPnLVault(vault: FamilyPnlLedger, artifacts: TPnlComputationArtifacts): HoldingsPnLVault { + const { currentTimestamp, debugTxLedgerKeys, priceData, ppsData, unknownTransferInPnlMode, vaultMetadata } = artifacts + const vaultKey = toVaultKey(vault.chainId, vault.vaultAddress) + const metadata = vaultMetadata.get(vaultKey) ?? null + const ppsMap = ppsData.get(vaultKey) + const pricePerShare = ppsMap ? getPPS(ppsMap, currentTimestamp) : null + const priceKey = metadata ? `${getChainPrefix(vault.chainId)}:${metadata.token.address.toLowerCase()}` : null + const tokenPriceMap = priceKey ? priceData.get(priceKey) : null + const tokenPrice = tokenPriceMap ? getPriceAtTimestamp(tokenPriceMap, currentTimestamp) : 0 + const resolvedPricePerShare = pricePerShare ?? 0 + const vaultSharesRaw = sumShares(vault.vaultLots) + const stakedSharesRaw = sumShares(vault.stakedLots) + const totalSharesRaw = vaultSharesRaw + stakedSharesRaw + const knownLots = [...vault.vaultLots, ...vault.stakedLots].filter((lot) => lot.costBasis !== null) + const unknownLots = [...vault.vaultLots, ...vault.stakedLots].filter((lot) => lot.costBasis === null) + const knownSharesRaw = sumShares(knownLots) + const unknownSharesRaw = sumShares(unknownLots) + + if (!metadata) { + if (shouldLogFinalLots(vault, debugTxLedgerKeys)) { + const includeDetailedLotLogs = shouldIncludeDetailedLotLogs() + debugTable( + 'pnl-journal', + `family transaction journal ${vault.vaultAddress}${vault.stakingVaultAddress ? ` (staking ${vault.stakingVaultAddress})` : ''}`, + serializeJournalRows(vault.debugJournal) + ) + debugLog('pnl-lots', 'final family lots missing metadata', { + chainId: vault.chainId, + familyVaultAddress: vault.vaultAddress, + stakingVaultAddress: vault.stakingVaultAddress, + status: 'missing_metadata', + vaultLots: summarizeLots(vault.vaultLots), + stakedLots: summarizeLots(vault.stakedLots), + vaultLotEntries: includeDetailedLotLogs ? serializeLots(vault.vaultLots) : undefined, + stakedLotEntries: includeDetailedLotLogs ? serializeLots(vault.stakedLots) : undefined, + realizedEntries: vault.realizedEntries.map((entry) => ({ + timestamp: entry.timestamp, + proceedsAssets: entry.proceedsAssets.toString(), + basisAssets: entry.basisAssets.toString(), + pnlAssets: entry.pnlAssets.toString() + })), + unknownCostBasisTransferInShares: vault.unknownCostBasisTransferInShares.toString(), + unmatchedTransferOutShares: vault.unmatchedTransferOutShares.toString() + }) + } + + return createMissingMetadataPnlVault(vault, unknownTransferInPnlMode, tokenPrice, resolvedPricePerShare) + } + + const vaultSharesFormatted = formatAmount(vaultSharesRaw, metadata.decimals) + const stakedSharesFormatted = formatAmount(stakedSharesRaw, metadata.decimals) + const sharesFormatted = formatAmount(totalSharesRaw, metadata.decimals) + const knownCostBasisUnderlying = formatAmount(sumKnownCostBasis(knownLots), metadata.token.decimals) + const vaultUnderlying = vaultSharesFormatted * resolvedPricePerShare + const stakedUnderlying = stakedSharesFormatted * resolvedPricePerShare + const currentUnderlying = vaultUnderlying + stakedUnderlying + const currentKnownUnderlying = formatAmount(knownSharesRaw, metadata.decimals) * resolvedPricePerShare + const currentUnknownUnderlying = formatAmount(unknownSharesRaw, metadata.decimals) * resolvedPricePerShare + const vaultValueUsd = vaultUnderlying * tokenPrice + const stakedValueUsd = stakedUnderlying * tokenPrice + const currentValueUsd = vaultValueUsd + stakedValueUsd + const isTransferOnlyHoldingsLedger = isCurrentTransferOnlyHoldingsLedger(vault) + + if (isTransferOnlyHoldingsLedger) { + const status = pricePerShare === null ? 'missing_pps' : tokenPrice > 0 ? 'ok' : 'missing_price' + const lightweightValuation = + unknownTransferInPnlMode === 'strict' + ? { + unknownCostBasisValueUsd: currentUnknownUnderlying * tokenPrice, + windfallPnlUsd: 0, + realizedPnlUnderlying: 0, + realizedPnlUsd: 0, + unrealizedPnlUnderlying: 0, + unrealizedPnlUsd: 0 + } + : unknownTransferInPnlMode === 'zero_basis' + ? { + unknownCostBasisValueUsd: 0, + windfallPnlUsd: 0, + realizedPnlUnderlying: 0, + realizedPnlUsd: 0, + unrealizedPnlUnderlying: currentUnknownUnderlying, + unrealizedPnlUsd: currentValueUsd + } + : { + unknownCostBasisValueUsd: 0, + windfallPnlUsd: currentValueUsd, + realizedPnlUnderlying: 0, + realizedPnlUsd: 0, + unrealizedPnlUnderlying: 0, + unrealizedPnlUsd: 0 + } + + return { + chainId: vault.chainId, + vaultAddress: vault.vaultAddress, + stakingVaultAddress: vault.stakingVaultAddress, + status, + costBasisStatus: 'partial', + unknownTransferInPnlMode, + shares: totalSharesRaw.toString(), + sharesFormatted, + vaultShares: vaultSharesRaw.toString(), + vaultSharesFormatted, + stakedShares: stakedSharesRaw.toString(), + stakedSharesFormatted, + knownCostBasisShares: knownSharesRaw.toString(), + unknownCostBasisShares: unknownSharesRaw.toString(), + pricePerShare: resolvedPricePerShare, + tokenPrice, + currentUnderlying, + vaultUnderlying, + stakedUnderlying, + currentKnownUnderlying, + currentUnknownUnderlying, + knownCostBasisUnderlying, + knownCostBasisUsd: 0, + currentValueUsd, + vaultValueUsd, + stakedValueUsd, + unknownCostBasisValueUsd: lightweightValuation.unknownCostBasisValueUsd, + windfallPnlUsd: lightweightValuation.windfallPnlUsd, + realizedPnlUnderlying: lightweightValuation.realizedPnlUnderlying, + realizedPnlUsd: lightweightValuation.realizedPnlUsd, + unrealizedPnlUnderlying: lightweightValuation.unrealizedPnlUnderlying, + unrealizedPnlUsd: lightweightValuation.unrealizedPnlUsd, + totalPnlUsd: lightweightValuation.realizedPnlUsd + lightweightValuation.unrealizedPnlUsd, + totalEconomicGainUsd: + lightweightValuation.realizedPnlUsd + + lightweightValuation.unrealizedPnlUsd + + lightweightValuation.windfallPnlUsd, + totalDepositedUnderlying: formatAmount(vault.totalDepositedAssets, metadata.token.decimals), + totalWithdrawnUnderlying: formatAmount(vault.totalWithdrawnAssets, metadata.token.decimals), + eventCounts: toHoldingsPnlEventCounts(vault), + metadata: { + symbol: metadata.token.symbol, + decimals: metadata.decimals, + assetDecimals: metadata.token.decimals, + tokenAddress: metadata.token.address, + category: metadata.category + } + } + } + + const knownCostBasisUsd = getKnownLotsCostBasisUsd(knownLots, metadata.token.decimals, tokenPriceMap, tokenPrice) + const baseRealizedPnlUnderlying = vault.realizedEntries.reduce( + (total, entry) => total + formatAmount(entry.pnlAssets, metadata.token.decimals), + 0 + ) + const baseRealizedPnlUsd = vault.realizedEntries.reduce((total, entry) => { + const realizedTokenPrice = tokenPriceMap ? getPriceAtTimestamp(tokenPriceMap, entry.timestamp) : 0 + const proceedsUsd = formatAmount(entry.proceedsAssets, metadata.token.decimals) * realizedTokenPrice + const basisUsd = getKnownLotsCostBasisUsd(entry.consumedLots, metadata.token.decimals, tokenPriceMap, tokenPrice) + return total + (proceedsUsd - basisUsd) + }, 0) + const baseUnrealizedPnlUnderlying = pricePerShare === null ? 0 : currentKnownUnderlying - knownCostBasisUnderlying + const baseUnrealizedPnlUsd = currentKnownUnderlying * tokenPrice - knownCostBasisUsd + const valuationState = applyUnknownTransferInModeAdjustment({ + unknownTransferInPnlMode, + vault, + unknownLots, + metadata, + ppsMap, + tokenPriceMap, + tokenPrice, + currentTimestamp, + resolvedPricePerShare, + currentUnknownUnderlying, + baseRealizedPnlUnderlying, + baseRealizedPnlUsd, + baseUnrealizedPnlUnderlying, + baseUnrealizedPnlUsd + }) + const costBasisStatus = + vault.unknownCostBasisTransferInCount === 0 && + vault.withdrawalsWithUnknownCostBasis === 0 && + vault.unmatchedTransferOutShares === ZERO + ? 'complete' + : 'partial' + const status = pricePerShare === null ? 'missing_pps' : tokenPrice > 0 ? 'ok' : 'missing_price' + + if (shouldLogFinalLots(vault, debugTxLedgerKeys)) { + const includeDetailedLotLogs = shouldIncludeDetailedLotLogs() + debugTable( + 'pnl-journal', + `family transaction journal ${vault.vaultAddress}${vault.stakingVaultAddress ? ` (staking ${vault.stakingVaultAddress})` : ''}`, + serializeJournalRows(vault.debugJournal, metadata.decimals, metadata.token.decimals) + ) + debugLog('pnl-lots', 'final family lots', { + chainId: vault.chainId, + familyVaultAddress: vault.vaultAddress, + stakingVaultAddress: vault.stakingVaultAddress, + status, + costBasisStatus, + symbol: metadata.token.symbol, + shareDecimals: metadata.decimals, + assetDecimals: metadata.token.decimals, + vaultLots: summarizeLots(vault.vaultLots), + stakedLots: summarizeLots(vault.stakedLots), + vaultLotEntries: includeDetailedLotLogs + ? serializeLots(vault.vaultLots, metadata.decimals, metadata.token.decimals) + : undefined, + stakedLotEntries: includeDetailedLotLogs + ? serializeLots(vault.stakedLots, metadata.decimals, metadata.token.decimals) + : undefined, + realizedEntries: vault.realizedEntries.map((entry) => ({ + timestamp: entry.timestamp, + proceedsAssets: entry.proceedsAssets.toString(), + basisAssets: entry.basisAssets.toString(), + pnlAssets: entry.pnlAssets.toString(), + pnlAssetsFormatted: formatAmount(entry.pnlAssets, metadata.token.decimals) + })), + unknownCostBasisTransferInShares: vault.unknownCostBasisTransferInShares.toString(), + unmatchedTransferOutShares: vault.unmatchedTransferOutShares.toString() + }) + } + + return { + chainId: vault.chainId, + vaultAddress: vault.vaultAddress, + stakingVaultAddress: vault.stakingVaultAddress, + status, + costBasisStatus, + unknownTransferInPnlMode, + shares: totalSharesRaw.toString(), + sharesFormatted, + vaultShares: vaultSharesRaw.toString(), + vaultSharesFormatted, + stakedShares: stakedSharesRaw.toString(), + stakedSharesFormatted, + knownCostBasisShares: knownSharesRaw.toString(), + unknownCostBasisShares: unknownSharesRaw.toString(), + pricePerShare: resolvedPricePerShare, + tokenPrice, + currentUnderlying, + vaultUnderlying, + stakedUnderlying, + currentKnownUnderlying, + currentUnknownUnderlying, + knownCostBasisUnderlying, + knownCostBasisUsd, + currentValueUsd, + vaultValueUsd, + stakedValueUsd, + unknownCostBasisValueUsd: valuationState.unknownCostBasisValueUsd, + windfallPnlUsd: valuationState.windfallPnlUsd, + realizedPnlUnderlying: valuationState.realizedPnlUnderlying, + realizedPnlUsd: valuationState.realizedPnlUsd, + unrealizedPnlUnderlying: valuationState.unrealizedPnlUnderlying, + unrealizedPnlUsd: valuationState.unrealizedPnlUsd, + totalPnlUsd: valuationState.realizedPnlUsd + valuationState.unrealizedPnlUsd, + totalEconomicGainUsd: + valuationState.realizedPnlUsd + valuationState.unrealizedPnlUsd + valuationState.windfallPnlUsd, + totalDepositedUnderlying: formatAmount(vault.totalDepositedAssets, metadata.token.decimals), + totalWithdrawnUnderlying: formatAmount(vault.totalWithdrawnAssets, metadata.token.decimals), + eventCounts: toHoldingsPnlEventCounts(vault), + metadata: { + symbol: metadata.token.symbol, + decimals: metadata.decimals, + assetDecimals: metadata.token.decimals, + tokenAddress: metadata.token.address, + category: metadata.category + } + } +} + +function materializeHoldingsPnLVaults(artifacts: TPnlComputationArtifacts): HoldingsPnLVault[] { + const pnlVaults = artifacts.vaults + .map((vault) => materializeHoldingsPnLVault(vault, artifacts)) + .sort((a, b) => b.currentValueUsd - a.currentValueUsd) + + debugLog('pnl', 'materialized pnl vault rows', { + vaults: pnlVaults.length, + missingMetadata: pnlVaults.filter((vault) => vault.status === 'missing_metadata').length, + missingPps: pnlVaults.filter((vault) => vault.status === 'missing_pps').length, + missingPrice: pnlVaults.filter((vault) => vault.status === 'missing_price').length, + partialCostBasis: pnlVaults.filter((vault) => vault.costBasisStatus === 'partial').length + }) + + return pnlVaults +} + +function materializeHoldingsPnLDrilldownVault( + vault: FamilyPnlLedger, + artifacts: TPnlComputationArtifacts +): HoldingsPnLDrilldownVault { + const baseVault = materializeHoldingsPnLVault(vault, artifacts) + const metadata = artifacts.vaultMetadata.get(toVaultKey(vault.chainId, vault.vaultAddress)) ?? null + + if (!metadata) { + return { + ...baseVault, + currentLots: { + vault: vault.vaultLots.map((lot, index) => ({ + index, + shares: lot.shares.toString(), + sharesFormatted: 0, + costBasis: lot.costBasis?.toString() ?? null, + costBasisFormatted: null, + acquiredAt: lot.acquiredAt ?? null, + costBasisUsd: null, + pricePerShareAtAcquisition: 0, + tokenPriceAtAcquisition: 0, + currentUnderlying: 0, + currentValueUsd: 0 + })), + staked: vault.stakedLots.map((lot, index) => ({ + index, + shares: lot.shares.toString(), + sharesFormatted: 0, + costBasis: lot.costBasis?.toString() ?? null, + costBasisFormatted: null, + acquiredAt: lot.acquiredAt ?? null, + costBasisUsd: null, + pricePerShareAtAcquisition: 0, + tokenPriceAtAcquisition: 0, + currentUnderlying: 0, + currentValueUsd: 0 + })) + }, + realizedEntries: [], + rewardTransferInEntries: [], + unknownTransferInEntries: [], + unknownWithdrawalEntries: [], + journal: [] + } + } + + const vaultKey = toVaultKey(vault.chainId, vault.vaultAddress) + const ppsMap = artifacts.ppsData.get(vaultKey) + const priceKey = `${getChainPrefix(vault.chainId)}:${metadata.token.address.toLowerCase()}` + const tokenPriceMap = artifacts.priceData.get(priceKey) ?? null + + return { + ...baseVault, + currentLots: { + vault: vault.vaultLots.map((lot, index) => + serializeLotForResponse({ + lot, + index, + shareDecimals: metadata.decimals, + assetDecimals: metadata.token.decimals, + resolvedPricePerShare: baseVault.pricePerShare, + currentTokenPrice: baseVault.tokenPrice, + ppsMap, + tokenPriceMap + }) + ), + staked: vault.stakedLots.map((lot, index) => + serializeLotForResponse({ + lot, + index, + shareDecimals: metadata.decimals, + assetDecimals: metadata.token.decimals, + resolvedPricePerShare: baseVault.pricePerShare, + currentTokenPrice: baseVault.tokenPrice, + ppsMap, + tokenPriceMap + }) + ) + }, + realizedEntries: vault.realizedEntries.map((entry) => + serializeRealizedEntryForResponse({ + entry, + shareDecimals: metadata.decimals, + assetDecimals: metadata.token.decimals, + currentTokenPrice: baseVault.tokenPrice, + tokenPriceMap, + resolvedPricePerShare: baseVault.pricePerShare, + ppsMap + }) + ), + rewardTransferInEntries: vault.rewardTransferInEntries.map((entry) => + serializeRewardTransferInEntryForResponse({ + entry, + shareDecimals: metadata.decimals, + currentTokenPrice: baseVault.tokenPrice, + resolvedPricePerShare: baseVault.pricePerShare, + ppsMap, + tokenPriceMap + }) + ), + unknownTransferInEntries: vault.unknownTransferInEntries.map((entry) => + serializeUnknownTransferInEntryForResponse({ + entry, + shareDecimals: metadata.decimals, + currentTokenPrice: baseVault.tokenPrice, + resolvedPricePerShare: baseVault.pricePerShare, + ppsMap, + tokenPriceMap + }) + ), + unknownWithdrawalEntries: vault.unknownWithdrawalEntries.map((entry) => + serializeUnknownWithdrawalEntryForResponse({ + entry, + shareDecimals: metadata.decimals, + assetDecimals: metadata.token.decimals, + currentTokenPrice: baseVault.tokenPrice, + tokenPriceMap, + resolvedPricePerShare: baseVault.pricePerShare, + ppsMap + }) + ), + journal: vault.debugJournal.map((row) => + serializeJournalEntryForResponse({ + row, + shareDecimals: metadata.decimals, + assetDecimals: metadata.token.decimals + }) + ) + } +} + +export async function getHoldingsPnL( + userAddress: string, + version: VaultVersion = 'all', + unknownTransferInPnlMode: UnknownTransferInPnlMode = 'windfall', + fetchType: HoldingsEventFetchType = 'seq', + paginationMode: HoldingsEventPaginationMode = 'paged' +): Promise { + const artifacts = await computeHoldingsPnLArtifacts( + userAddress, + version, + unknownTransferInPnlMode, + fetchType, + paginationMode + ) + + if (artifacts.vaults.length === 0) { + return createEmptyHoldingsPnlResponse(userAddress, version, unknownTransferInPnlMode) + } + + const pnlVaults = materializeHoldingsPnLVaults(artifacts) + const summary = summarizePnlVaults(pnlVaults) + debugLog('pnl', 'completed holdings pnl calculation', { + unknownTransferInPnlMode, + totalVaults: summary.totalVaults, + totalCurrentValueUsd: summary.totalCurrentValueUsd, + totalWindfallPnlUsd: summary.totalWindfallPnlUsd, + totalRealizedPnlUsd: summary.totalRealizedPnlUsd, + totalUnrealizedPnlUsd: summary.totalUnrealizedPnlUsd, + totalPnlUsd: summary.totalPnlUsd, + totalEconomicGainUsd: summary.totalEconomicGainUsd, + isComplete: summary.isComplete + }) + + return { + address: userAddress, + version, + unknownTransferInPnlMode, + generatedAt: new Date().toISOString(), + summary, + vaults: pnlVaults + } +} + +export async function getHoldingsPnLDrilldown( + userAddress: string, + version: VaultVersion = 'all', + unknownTransferInPnlMode: UnknownTransferInPnlMode = 'windfall', + fetchType: HoldingsEventFetchType = 'seq', + paginationMode: HoldingsEventPaginationMode = 'paged', + vaultFilter?: string | null +): Promise { + const artifacts = await computeHoldingsPnLArtifacts( + userAddress, + version, + unknownTransferInPnlMode, + fetchType, + paginationMode + ) + + if (artifacts.vaults.length === 0) { + return { + ...createEmptyHoldingsPnlResponse(userAddress, version, unknownTransferInPnlMode), + vaults: [] + } + } + + const filteredVaults = + vaultFilter === undefined || vaultFilter === null + ? artifacts.vaults + : artifacts.vaults.filter( + (vault) => + vault.vaultAddress === vaultFilter.toLowerCase() || vault.stakingVaultAddress === vaultFilter.toLowerCase() + ) + + const drilldownVaults = filteredVaults + .map((vault) => materializeHoldingsPnLDrilldownVault(vault, artifacts)) + .sort((a, b) => b.currentValueUsd - a.currentValueUsd) + const summary = summarizePnlVaults(drilldownVaults) + + debugLog('pnl', 'completed holdings pnl drilldown calculation', { + unknownTransferInPnlMode, + vaultFilter: vaultFilter?.toLowerCase() ?? null, + totalVaults: summary.totalVaults, + totalCurrentValueUsd: summary.totalCurrentValueUsd, + totalPnlUsd: summary.totalPnlUsd, + totalEconomicGainUsd: summary.totalEconomicGainUsd + }) + + return { + address: userAddress, + version, + unknownTransferInPnlMode, + generatedAt: new Date().toISOString(), + summary, + vaults: drilldownVaults + } +} 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..b547bc9f5 --- /dev/null +++ b/api/lib/holdings/services/pnlSimple.test.ts @@ -0,0 +1,1346 @@ +import { describe, expect, it } from 'vitest' +import type { VaultMetadata } from '../types' +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 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('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('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('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('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('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('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..7ba7417d2 --- /dev/null +++ b/api/lib/holdings/services/pnlSimple.ts @@ -0,0 +1,2254 @@ +import { formatUnits } from 'viem' +import { config } from '../config' +import type { VaultMetadata } from '../types' +import { debugError, debugLog } from './debug' +import { + fetchHistoricalPricesForTokenTimestamps, + getChainPrefix, + getPriceAtTimestamp, + type THistoricalPriceRequest +} from './defillama' +import { + fetchRawUserPnlEvents, + type HoldingsEventFetchType, + type HoldingsEventPaginationMode, + type VaultVersion +} from './graphql' +import { + generateDailyTimestamps, + generateDailyTimestampsFromRange, + timestampToDateString, + toSettledDayTimestamp +} from './holdings' +import { fetchMultipleVaultsPPS, getPPS } from './kong' +import { + deriveNestedVaultAssetPriceData, + expandNestedVaultAssetPriceRequests, + getAssetVaultMetadataLookupIdentifiers, + getNestedVaultPpsIdentifiersFromPriceRequests, + mergeVaultIdentifiers +} from './nestedVaultPrices' +import { buildRawPnlEvents } from './pnl' +import { lowerCaseAddress, toVaultKey, ZERO } from './pnlShared' +import type { TRawPnlEvent } from './pnlTypes' +import { getStakingVaultAddress } from './staking' +import { fetchMultipleVaultsMetadata } from './vaults' + +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 + +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 getVaultIdentifiers(events: TRawPnlEvent[]): Array<{ chainId: number; vaultAddress: string }> { + 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()) + ).map(([, identifier]) => identifier) +} + +function filterEventsByRequestedVault( + events: TRawPnlEvent[], + requestedVault?: { chainId: number; vaultAddress: string } +): TRawPnlEvent[] { + if (!requestedVault) { + return events + } + + return events.filter( + (event) => + event.chainId === requestedVault.chainId && + event.familyVaultAddress === lowerCaseAddress(requestedVault.vaultAddress) + ) +} + +function filterVaultIdentifiersByRequestedVault( + vaults: Array<{ chainId: number; vaultAddress: string }>, + requestedVault?: { chainId: number; vaultAddress: string } +): Array<{ chainId: number; vaultAddress: string }> { + if (!requestedVault) { + return vaults + } + + const requestedVaultAddress = lowerCaseAddress(requestedVault.vaultAddress) + return vaults.filter( + (vault) => vault.chainId === requestedVault.chainId && vault.vaultAddress === requestedVaultAddress + ) +} + +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 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) + })) +} + +async function resolveNestedVaultAssetMetadata( + vaultMetadata: Map +): Promise> { + const assetVaultIdentifiers = getAssetVaultMetadataLookupIdentifiers(vaultMetadata) + + if (assetVaultIdentifiers.length === 0) { + return vaultMetadata + } + + const assetVaultMetadata = await fetchMultipleVaultsMetadata(assetVaultIdentifiers, { skipSnapshotFallback: true }) + return new Map([...vaultMetadata, ...assetVaultMetadata]) +} + +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) + } 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 } + ]) + 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 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) + ledgers.set( + vaultKey, + addReceipt(currentLedger, { + shares: event.shares, + baselineUnderlying: formatAmount(event.assets, assetDecimals), + receiptTimestamp: event.blockTimestamp, + receiptPriceUsd, + receiptPriceEth, + receiptKind: 'deposit', + transactionHash: event.transactionHash + }) + ) + return ledgers + } + + if (event.kind === 'withdrawal') { + ledgers.set( + vaultKey, + addExit(currentLedger, { + shares: event.shares, + exitUnderlying: formatAmount(event.assets, assetDecimals), + 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: 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 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(config.historyDays, 1).map((timestamp) => toSettledDayTimestamp(timestamp)) + } + + if (events.length === 0) { + return [] + } + + const latestSettledTimestamp = generateDailyTimestamps(1, 1)[0] + const firstEventTimestamp = sortEvents(events)[0]?.blockTimestamp ?? latestSettledTimestamp + return generateDailyTimestampsFromRange(firstEventTimestamp, latestSettledTimestamp).map((timestamp) => + toSettledDayTimestamp(timestamp) + ) +} + +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 +}): 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 selectedVault = args.selectedVaultKey + ? vaults.find((vault) => toVaultKey(vault.chainId, vault.vaultAddress) === args.selectedVaultKey) + : null + 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, + ...(args.selectedVaultKey + ? { + currentUnderlying: selectedVault?.currentUnderlying ?? 0, + growthUnderlying: selectedVault?.growthUnderlying ?? 0, + sharesFormatted: selectedVault?.sharesFormatted ?? 0, + pricePerShare: selectedVault?.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 getHoldingsPnLSimple( + userAddress: string, + version: VaultVersion = 'all', + fetchType: HoldingsEventFetchType = 'seq', + paginationMode: HoldingsEventPaginationMode = 'paged' +): Promise { + debugLog('pnl-simple', 'starting holdings simple pnl calculation', { version, fetchType, paginationMode }) + const rawContext = await fetchRawUserPnlEvents(userAddress, 'all', undefined, fetchType, paginationMode) + const rawEvents = buildRawPnlEvents(rawContext) + const rawVaultIdentifiers = getVaultIdentifiers(rawEvents) + const resolvedVaultMetadata = await fetchMultipleVaultsMetadata(rawVaultIdentifiers) + const effectiveEvents = buildEffectiveSimpleEvents( + filterEventsByAuthoritativeVersion(rawEvents, resolvedVaultMetadata, version), + userAddress + ) + const filteredVaultIdentifiers = getVaultIdentifiers(effectiveEvents) + const baseVaultMetadata = filteredVaultIdentifiers.reduce>((filtered, vault) => { + const key = toVaultKey(vault.chainId, vault.vaultAddress) + const metadata = resolvedVaultMetadata.get(key) + + if (metadata) { + filtered.set(key, metadata) + } + + return filtered + }, new Map()) + const vaultMetadata = await resolveNestedVaultAssetMetadata(baseVaultMetadata) + const currentTimestamp = Math.floor(Date.now() / 1000) + + debugLog('pnl-simple', 'loaded address-scoped events', { + version, + addressDeposits: rawContext.addressEvents.deposits.length, + addressWithdrawals: rawContext.addressEvents.withdrawals.length, + addressTransfersIn: rawContext.addressEvents.transfersIn.length, + addressTransfersOut: rawContext.addressEvents.transfersOut.length, + rawEvents: rawEvents.length, + effectiveEvents: effectiveEvents.length, + vaults: filteredVaultIdentifiers.length + }) + + if (effectiveEvents.length === 0 || filteredVaultIdentifiers.length === 0) { + return { + address: lowerCaseAddress(userAddress), + version, + generatedAt: new Date().toISOString(), + summary: buildSummary([]), + vaults: [] + } + } + + const baseReceiptPriceRequests = buildReceiptPriceRequests({ + events: effectiveEvents, + metadata: vaultMetadata, + userAddress, + currentTimestamp + }) + const receiptPriceRequests = expandNestedVaultAssetPriceRequests(baseReceiptPriceRequests, vaultMetadata) + const uniqueReceiptPriceTimestamps = new Set(receiptPriceRequests.flatMap((request) => request.timestamps)) + + debugLog('pnl-simple', 'prepared targeted receipt price requests', { + tokens: receiptPriceRequests.length, + uniqueTimestamps: uniqueReceiptPriceTimestamps.size, + pricePoints: countReceiptPricePoints(receiptPriceRequests), + bucketSeconds: RECEIPT_PRICE_BUCKET_SECONDS + }) + + const ppsIdentifiers = mergeVaultIdentifiers([ + ...filteredVaultIdentifiers, + ...getNestedVaultPpsIdentifiersFromPriceRequests(baseReceiptPriceRequests, vaultMetadata) + ]) + const [ppsData, fetchedPriceData] = await Promise.all([ + fetchMultipleVaultsPPS(ppsIdentifiers), + fetchReceiptPrices(receiptPriceRequests) + ]) + const priceData = deriveNestedVaultAssetPriceData({ + priceData: fetchedPriceData, + priceRequests: receiptPriceRequests, + vaultMetadata, + ppsData + }) + const ledgers = buildProtocolReturnLedgers({ + events: effectiveEvents, + userAddress, + metadata: vaultMetadata, + ppsData, + priceData, + currentTimestamp + }) + const vaults = materializeProtocolReturnVaults({ + ledgers, + metadata: vaultMetadata, + ppsData, + currentTimestamp + }).sort((a, b) => b.baselineWeightUsd - a.baselineWeightUsd) + const summary = buildSummary(vaults) + + debugLog('pnl-simple', 'completed holdings simple pnl calculation', { + version, + totalVaults: summary.totalVaults, + baselineWeightUsd: summary.baselineWeightUsd, + growthWeightUsd: summary.growthWeightUsd, + protocolReturnPct: summary.protocolReturnPct, + isComplete: summary.isComplete + }) + + return { + address: lowerCaseAddress(userAddress), + version, + generatedAt: new Date().toISOString(), + summary, + vaults + } +} + +export async function getHoldingsPnLSimpleHistory( + userAddress: string, + version: VaultVersion = 'all', + fetchType: HoldingsEventFetchType = 'seq', + paginationMode: HoldingsEventPaginationMode = 'paged', + timeframe: '1y' | 'all' = '1y', + vaultAddress?: string, + vaultChainId?: number +): Promise { + debugLog('pnl-simple-history', 'starting holdings simple pnl history calculation', { + version, + fetchType, + paginationMode, + timeframe + }) + + const requestedVault = + vaultAddress && Number.isInteger(vaultChainId) + ? { + chainId: Number(vaultChainId), + vaultAddress: lowerCaseAddress(vaultAddress) + } + : undefined + const rawContext = await fetchRawUserPnlEvents(userAddress, 'all', undefined, fetchType, paginationMode) + const rawEvents = buildRawPnlEvents(rawContext) + const rawVaultIdentifiers = getVaultIdentifiers(rawEvents) + const resolvedVaultMetadata = await fetchMultipleVaultsMetadata(rawVaultIdentifiers) + const versionFilteredEvents = filterEventsByAuthoritativeVersion(rawEvents, resolvedVaultMetadata, version) + const effectiveEvents = filterEventsByRequestedVault( + buildEffectiveSimpleEvents(versionFilteredEvents, userAddress), + requestedVault + ) + const filteredVaultIdentifiers = filterVaultIdentifiersByRequestedVault( + getVaultIdentifiers(effectiveEvents), + requestedVault + ) + const baseVaultMetadata = filteredVaultIdentifiers.reduce>((filtered, vault) => { + const key = toVaultKey(vault.chainId, vault.vaultAddress) + const metadata = resolvedVaultMetadata.get(key) + + if (metadata) { + filtered.set(key, metadata) + } + + return filtered + }, new Map()) + const vaultMetadata = await resolveNestedVaultAssetMetadata(baseVaultMetadata) + + if (effectiveEvents.length === 0 || filteredVaultIdentifiers.length === 0) { + 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] ?? Math.floor(Date.now() / 1000) + 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) + ]) + const [ppsData, fetchedPriceData, ethPriceData] = await Promise.all([ + fetchMultipleVaultsPPS(ppsIdentifiers), + fetchReceiptPrices(receiptPriceRequests), + fetchEthReceiptPrices(ethReceiptPriceTimestamps) + ]) + const priceData = deriveNestedVaultAssetPriceData({ + priceData: fetchedPriceData, + priceRequests: receiptPriceRequests, + vaultMetadata, + ppsData + }) + + const finalLedgers = buildProtocolReturnLedgers({ + events: effectiveEvents, + userAddress, + metadata: vaultMetadata, + ppsData, + priceData, + ethPriceData, + currentTimestamp: latestTimestamp + }) + const finalVaults = materializeProtocolReturnVaults({ + ledgers: finalLedgers, + metadata: vaultMetadata, + ppsData, + currentTimestamp: latestTimestamp + }) + const selectedHistoryFamilies = selectHistoryFamilies(finalVaults) + const history = buildProtocolReturnHistorySeries({ + events: effectiveEvents, + userAddress, + metadata: vaultMetadata, + ppsData, + priceData, + ethPriceData, + timestamps, + selectedVaultKey: + requestedVault && filteredVaultIdentifiers.length > 0 + ? toVaultKey(filteredVaultIdentifiers[0]!.chainId, filteredVaultIdentifiers[0]!.vaultAddress) + : undefined + }) + const familySeries = buildProtocolReturnFamilyHistorySeries({ + events: effectiveEvents, + userAddress, + metadata: vaultMetadata, + ppsData, + priceData, + ethPriceData, + timestamps, + selectedVaults: requestedVault ? [] : selectedHistoryFamilies + }) + const openBaselineCompositionUsd = buildOpenBaselineCompositionUsd({ + ledgers: finalLedgers, + metadata: vaultMetadata + }) + const { recommendedGrowthDisplay, recommendedGrowthDisplayReason } = + resolveRecommendedGrowthDisplay(openBaselineCompositionUsd) + + debugLog('pnl-simple-history', 'completed holdings simple pnl history calculation', { + version, + timeframe, + points: history.length, + totalVaults: finalVaults.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/pnlTypes.ts b/api/lib/holdings/services/pnlTypes.ts new file mode 100644 index 000000000..3732e75c4 --- /dev/null +++ b/api/lib/holdings/services/pnlTypes.ts @@ -0,0 +1,400 @@ +import type { VaultVersion } from './graphql' + +export type TLocation = 'vault' | 'staked' + +export type TLot = { + shares: bigint + costBasis: bigint | null + acquiredAt?: number +} + +export type TLotSummary = { + lotCount: number + totalShares: string + knownShares: string + unknownShares: string + totalKnownCostBasis: string +} + +export type TRealizedEntry = { + timestamp: number + proceedsAssets: bigint + basisAssets: bigint + pnlAssets: bigint + consumedLots: TLot[] +} + +export type TUnknownTransferInEntry = { + timestamp: number + shares: bigint + location: TLocation +} + +export type TRewardTransferInEntry = { + timestamp: number + shares: bigint + location: TLocation + distributor: string +} + +export type TUnknownWithdrawalEntry = { + timestamp: number + shares: bigint + proceedsAssets: bigint + consumedLots: TLot[] +} + +export type UnknownTransferInPnlMode = 'strict' | 'zero_basis' | 'windfall' + +export type TPnlDebugJournalRow = { + timestamp: number + txHash: string + familyVaultAddress: string + stakingVaultAddress: string | null + view: string + hasAddressActivity: boolean + rawEvents: string + depositShares: string + depositAssets: string + withdrawShares: string + withdrawAssets: string + stakeShares: string + unstakeShares: string + rewardInVaultShares: string + rewardInStakedShares: string + unknownInVaultShares: string + unknownInStakedShares: string + transferOutVaultShares: string + transferOutStakedShares: string + realizedKnownShares: string + realizedProceedsAssets: string + realizedBasisAssets: string + realizedPnlAssets: string + vaultLotsBefore: TLotSummary + stakedLotsBefore: TLotSummary + vaultLotsAfter: TLotSummary + stakedLotsAfter: TLotSummary +} + +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 + } + +export interface FamilyPnlLedger { + chainId: number + vaultAddress: string + stakingVaultAddress: string | null + vaultLots: TLot[] + stakedLots: TLot[] + totalDepositedAssets: bigint + totalWithdrawnAssets: bigint + unknownCostBasisTransferInCount: number + unknownCostBasisTransferInShares: bigint + withdrawalsWithUnknownCostBasis: number + unmatchedTransferOutCount: number + unmatchedTransferOutShares: bigint + realizedEntries: TRealizedEntry[] + rewardTransferInEntries: TRewardTransferInEntry[] + unknownTransferInEntries: TUnknownTransferInEntry[] + unknownWithdrawalEntries: TUnknownWithdrawalEntry[] + debugJournal: TPnlDebugJournalRow[] + eventCounts: { + underlyingDeposits: number + underlyingWithdrawals: number + stakes: number + unstakes: number + rewardTransfersIn: number + externalTransfersIn: number + externalTransfersOut: number + migrationsIn: number + migrationsOut: number + } +} + +export interface HoldingsPnLVault { + chainId: number + vaultAddress: string + stakingVaultAddress: string | null + status: 'ok' | 'missing_metadata' | 'missing_price' | 'missing_pps' + costBasisStatus: 'complete' | 'partial' + unknownTransferInPnlMode: UnknownTransferInPnlMode + shares: string + sharesFormatted: number + vaultShares: string + vaultSharesFormatted: number + stakedShares: string + stakedSharesFormatted: number + knownCostBasisShares: string + unknownCostBasisShares: string + pricePerShare: number + tokenPrice: number + currentUnderlying: number + vaultUnderlying: number + stakedUnderlying: number + currentKnownUnderlying: number + currentUnknownUnderlying: number + knownCostBasisUnderlying: number + knownCostBasisUsd: number + currentValueUsd: number + vaultValueUsd: number + stakedValueUsd: number + unknownCostBasisValueUsd: number + windfallPnlUsd: number + realizedPnlUnderlying: number + realizedPnlUsd: number + unrealizedPnlUnderlying: number + unrealizedPnlUsd: number + totalPnlUsd: number + totalEconomicGainUsd: number + totalDepositedUnderlying: number + totalWithdrawnUnderlying: number + eventCounts: { + underlyingDeposits: number + underlyingWithdrawals: number + stakes: number + unstakes: number + rewardTransfersIn: number + externalTransfersIn: number + externalTransfersOut: number + migrationsIn: number + migrationsOut: number + unknownCostBasisTransfersIn: number + withdrawalsWithUnknownCostBasis: number + } + metadata: { + symbol: string + decimals: number + assetDecimals: number + tokenAddress: string + category: 'stable' | 'volatile' + } | null +} + +export interface HoldingsPnLCategoryBreakdown { + totalPnlUsd: number + totalEconomicGainUsd: number +} + +export type THoldingsPnlSummary = { + totalVaults: number + completeVaults: number + partialVaults: number + totalCurrentValueUsd: number + totalUnknownCostBasisValueUsd: number + totalWindfallPnlUsd: number + totalRealizedPnlUsd: number + totalUnrealizedPnlUsd: number + totalPnlUsd: number + totalEconomicGainUsd: number + byCategory: { + stable: HoldingsPnLCategoryBreakdown + volatile: HoldingsPnLCategoryBreakdown + } + isComplete: boolean +} + +export type THoldingsPnlLot = { + index: number + shares: string + sharesFormatted: number + costBasis: string | null + costBasisFormatted: number | null + acquiredAt: number | null + costBasisUsd: number | null + pricePerShareAtAcquisition: number + tokenPriceAtAcquisition: number + currentUnderlying: number + currentValueUsd: number +} + +export type THoldingsPnlRealizedEntry = { + timestamp: number + proceedsAssets: string + proceedsUnderlying: number + proceedsUsd: number + basisAssets: string + basisUnderlying: number + basisUsd: number + pnlAssets: string + pnlUnderlying: number + pnlUsd: number + consumedLots: THoldingsPnlLot[] +} + +export type THoldingsPnlUnknownTransferInEntry = { + timestamp: number + location: TLocation + shares: string + sharesFormatted: number + pricePerShareAtReceipt: number + tokenPriceAtReceipt: number + receiptUnderlying: number + receiptValueUsd: number +} + +export type THoldingsPnlRewardTransferInEntry = { + timestamp: number + location: TLocation + distributor: string + shares: string + sharesFormatted: number + pricePerShareAtReceipt: number + tokenPriceAtReceipt: number + receiptUnderlying: number + receiptValueUsd: number +} + +export type THoldingsPnlUnknownWithdrawalEntry = { + timestamp: number + shares: string + sharesFormatted: number + proceedsAssets: string + proceedsUnderlying: number + proceedsUsd: number + consumedLots: THoldingsPnlLot[] +} + +export type THoldingsPnlJournalLotSummary = TLotSummary & { + totalSharesFormatted: number + knownSharesFormatted: number + unknownSharesFormatted: number + totalKnownCostBasisFormatted: number +} + +export type THoldingsPnlJournalEntry = { + timestamp: number + txHash: string + view: string + hasAddressActivity: boolean + rawEvents: string + depositShares: string + depositSharesFormatted: number + depositAssets: string + depositAssetsFormatted: number + withdrawShares: string + withdrawSharesFormatted: number + withdrawAssets: string + withdrawAssetsFormatted: number + stakeShares: string + stakeSharesFormatted: number + unstakeShares: string + unstakeSharesFormatted: number + rewardInVaultShares: string + rewardInVaultSharesFormatted: number + rewardInStakedShares: string + rewardInStakedSharesFormatted: number + unknownInVaultShares: string + unknownInVaultSharesFormatted: number + unknownInStakedShares: string + unknownInStakedSharesFormatted: number + transferOutVaultShares: string + transferOutVaultSharesFormatted: number + transferOutStakedShares: string + transferOutStakedSharesFormatted: number + realizedKnownShares: string + realizedKnownSharesFormatted: number + realizedProceedsAssets: string + realizedProceedsAssetsFormatted: number + realizedBasisAssets: string + realizedBasisAssetsFormatted: number + realizedPnlAssets: string + realizedPnlAssetsFormatted: number + vaultLotsBefore: THoldingsPnlJournalLotSummary + stakedLotsBefore: THoldingsPnlJournalLotSummary + vaultLotsAfter: THoldingsPnlJournalLotSummary + stakedLotsAfter: THoldingsPnlJournalLotSummary +} + +export interface HoldingsPnLDrilldownVault extends HoldingsPnLVault { + currentLots: { + vault: THoldingsPnlLot[] + staked: THoldingsPnlLot[] + } + realizedEntries: THoldingsPnlRealizedEntry[] + rewardTransferInEntries: THoldingsPnlRewardTransferInEntry[] + unknownTransferInEntries: THoldingsPnlUnknownTransferInEntry[] + unknownWithdrawalEntries: THoldingsPnlUnknownWithdrawalEntry[] + journal: THoldingsPnlJournalEntry[] +} + +export interface HoldingsPnLResponse { + address: string + version: VaultVersion + unknownTransferInPnlMode: UnknownTransferInPnlMode + generatedAt: string + summary: THoldingsPnlSummary + vaults: HoldingsPnLVault[] +} + +export interface HoldingsPnLDrilldownResponse { + address: string + version: VaultVersion + unknownTransferInPnlMode: UnknownTransferInPnlMode + generatedAt: string + summary: THoldingsPnlSummary + vaults: HoldingsPnLDrilldownVault[] +} + +export type TPnlComputationState = { + unknownCostBasisValueUsd: number + windfallPnlUsd: number + realizedPnlUnderlying: number + realizedPnlUsd: number + unrealizedPnlUnderlying: number + unrealizedPnlUsd: number +} diff --git a/api/lib/holdings/services/pnlValuation.ts b/api/lib/holdings/services/pnlValuation.ts new file mode 100644 index 000000000..af9f700c3 --- /dev/null +++ b/api/lib/holdings/services/pnlValuation.ts @@ -0,0 +1,319 @@ +import type { VaultMetadata } from '../types' +import { getPriceAtTimestamp } from './defillama' +import type { VaultVersion } from './graphql' +import { getPPS } from './kong' +import { formatAmount, sumShares } from './pnlShared' +import type { + FamilyPnlLedger, + HoldingsPnLCategoryBreakdown, + HoldingsPnLResponse, + HoldingsPnLVault, + TLot, + TPnlComputationState, + UnknownTransferInPnlMode +} from './pnlTypes' + +function createEmptyCategoryBreakdown(): HoldingsPnLCategoryBreakdown { + return { + totalPnlUsd: 0, + totalEconomicGainUsd: 0 + } +} + +export function toHoldingsPnlEventCounts(vault: FamilyPnlLedger): HoldingsPnLVault['eventCounts'] { + return { + underlyingDeposits: vault.eventCounts.underlyingDeposits, + underlyingWithdrawals: vault.eventCounts.underlyingWithdrawals, + stakes: vault.eventCounts.stakes, + unstakes: vault.eventCounts.unstakes, + rewardTransfersIn: vault.eventCounts.rewardTransfersIn, + externalTransfersIn: vault.eventCounts.externalTransfersIn, + externalTransfersOut: vault.eventCounts.externalTransfersOut, + migrationsIn: vault.eventCounts.migrationsIn, + migrationsOut: vault.eventCounts.migrationsOut, + unknownCostBasisTransfersIn: vault.unknownCostBasisTransferInCount, + withdrawalsWithUnknownCostBasis: vault.withdrawalsWithUnknownCostBasis + } +} + +export function createEmptyHoldingsPnlResponse( + address: string, + version: VaultVersion, + unknownTransferInPnlMode: UnknownTransferInPnlMode +): HoldingsPnLResponse { + return { + address, + version, + unknownTransferInPnlMode, + generatedAt: new Date().toISOString(), + summary: { + totalVaults: 0, + completeVaults: 0, + partialVaults: 0, + totalCurrentValueUsd: 0, + totalUnknownCostBasisValueUsd: 0, + totalWindfallPnlUsd: 0, + totalRealizedPnlUsd: 0, + totalUnrealizedPnlUsd: 0, + totalPnlUsd: 0, + totalEconomicGainUsd: 0, + byCategory: { + stable: createEmptyCategoryBreakdown(), + volatile: createEmptyCategoryBreakdown() + }, + isComplete: true + }, + vaults: [] + } +} + +export function summarizePnlVaults(vaults: HoldingsPnLVault[]): HoldingsPnLResponse['summary'] { + return vaults.reduce( + (totals, vault) => { + const category = vault.metadata?.category ?? 'volatile' + + return { + totalVaults: totals.totalVaults + 1, + completeVaults: totals.completeVaults + (vault.costBasisStatus === 'complete' ? 1 : 0), + partialVaults: totals.partialVaults + (vault.costBasisStatus === 'partial' ? 1 : 0), + totalCurrentValueUsd: totals.totalCurrentValueUsd + vault.currentValueUsd, + totalUnknownCostBasisValueUsd: totals.totalUnknownCostBasisValueUsd + vault.unknownCostBasisValueUsd, + totalWindfallPnlUsd: totals.totalWindfallPnlUsd + vault.windfallPnlUsd, + totalRealizedPnlUsd: totals.totalRealizedPnlUsd + vault.realizedPnlUsd, + totalUnrealizedPnlUsd: totals.totalUnrealizedPnlUsd + vault.unrealizedPnlUsd, + totalPnlUsd: totals.totalPnlUsd + vault.totalPnlUsd, + totalEconomicGainUsd: totals.totalEconomicGainUsd + vault.totalEconomicGainUsd, + byCategory: { + stable: { + totalPnlUsd: totals.byCategory.stable.totalPnlUsd + (category === 'stable' ? vault.totalPnlUsd : 0), + totalEconomicGainUsd: + totals.byCategory.stable.totalEconomicGainUsd + (category === 'stable' ? vault.totalEconomicGainUsd : 0) + }, + volatile: { + totalPnlUsd: totals.byCategory.volatile.totalPnlUsd + (category === 'volatile' ? vault.totalPnlUsd : 0), + totalEconomicGainUsd: + totals.byCategory.volatile.totalEconomicGainUsd + + (category === 'volatile' ? vault.totalEconomicGainUsd : 0) + } + }, + isComplete: totals.isComplete && vault.costBasisStatus === 'complete' + } + }, + { + totalVaults: 0, + completeVaults: 0, + partialVaults: 0, + totalCurrentValueUsd: 0, + totalUnknownCostBasisValueUsd: 0, + totalWindfallPnlUsd: 0, + totalRealizedPnlUsd: 0, + totalUnrealizedPnlUsd: 0, + totalPnlUsd: 0, + totalEconomicGainUsd: 0, + byCategory: { + stable: createEmptyCategoryBreakdown(), + volatile: createEmptyCategoryBreakdown() + }, + isComplete: true + } + ) +} + +function getEstimatedUnknownUnderlyingAtReceipt( + shares: bigint, + timestamp: number, + shareDecimals: number, + ppsMap: Map | undefined, + fallbackPricePerShare: number +): number { + const receiptPricePerShare = + timestamp > 0 + ? ppsMap + ? (getPPS(ppsMap, timestamp) ?? fallbackPricePerShare) + : fallbackPricePerShare + : fallbackPricePerShare + + return formatAmount(shares, shareDecimals) * receiptPricePerShare +} + +function getTokenPriceForTimestamp( + tokenPriceMap: Map | null, + timestamp: number, + fallbackTokenPrice: number +): number { + return timestamp > 0 + ? tokenPriceMap + ? getPriceAtTimestamp(tokenPriceMap, timestamp) + : fallbackTokenPrice + : fallbackTokenPrice +} + +// Missing metadata is a hard stop for valuation, but we still surface the ledger so the caller +// can see that shares exist and that the accounting path reached this family. +export function createMissingMetadataPnlVault( + vault: FamilyPnlLedger, + unknownTransferInPnlMode: UnknownTransferInPnlMode, + tokenPrice: number, + resolvedPricePerShare: number +): HoldingsPnLVault { + const vaultSharesRaw = sumShares(vault.vaultLots) + const stakedSharesRaw = sumShares(vault.stakedLots) + const totalSharesRaw = vaultSharesRaw + stakedSharesRaw + const knownLots = [...vault.vaultLots, ...vault.stakedLots].filter((lot) => lot.costBasis !== null) + const unknownLots = [...vault.vaultLots, ...vault.stakedLots].filter((lot) => lot.costBasis === null) + + return { + chainId: vault.chainId, + vaultAddress: vault.vaultAddress, + stakingVaultAddress: vault.stakingVaultAddress, + status: 'missing_metadata', + costBasisStatus: 'partial', + unknownTransferInPnlMode, + shares: totalSharesRaw.toString(), + sharesFormatted: 0, + vaultShares: vaultSharesRaw.toString(), + vaultSharesFormatted: 0, + stakedShares: stakedSharesRaw.toString(), + stakedSharesFormatted: 0, + knownCostBasisShares: sumShares(knownLots).toString(), + unknownCostBasisShares: sumShares(unknownLots).toString(), + pricePerShare: resolvedPricePerShare, + tokenPrice, + currentUnderlying: 0, + vaultUnderlying: 0, + stakedUnderlying: 0, + currentKnownUnderlying: 0, + currentUnknownUnderlying: 0, + knownCostBasisUnderlying: 0, + knownCostBasisUsd: 0, + currentValueUsd: 0, + vaultValueUsd: 0, + stakedValueUsd: 0, + unknownCostBasisValueUsd: 0, + windfallPnlUsd: 0, + realizedPnlUnderlying: 0, + realizedPnlUsd: 0, + unrealizedPnlUnderlying: 0, + unrealizedPnlUsd: 0, + totalPnlUsd: 0, + totalEconomicGainUsd: 0, + totalDepositedUnderlying: 0, + totalWithdrawnUnderlying: 0, + eventCounts: toHoldingsPnlEventCounts(vault), + metadata: null + } +} + +// Unknown transfer-ins are the main heuristic branch in the whole pipeline. +// We keep the strict / zero-basis / windfall behavior isolated here so the materialization +// step reads as "compute known PnL, then apply the chosen unknown-lot policy". +export function applyUnknownTransferInModeAdjustment(args: { + unknownTransferInPnlMode: UnknownTransferInPnlMode + vault: FamilyPnlLedger + unknownLots: TLot[] + metadata: VaultMetadata + ppsMap: Map | undefined + tokenPriceMap: Map | null + tokenPrice: number + currentTimestamp: number + resolvedPricePerShare: number + currentUnknownUnderlying: number + baseRealizedPnlUnderlying: number + baseRealizedPnlUsd: number + baseUnrealizedPnlUnderlying: number + baseUnrealizedPnlUsd: number +}): TPnlComputationState { + const { + unknownTransferInPnlMode, + vault, + unknownLots, + metadata, + ppsMap, + tokenPriceMap, + tokenPrice, + currentTimestamp, + resolvedPricePerShare, + currentUnknownUnderlying, + baseRealizedPnlUnderlying, + baseRealizedPnlUsd, + baseUnrealizedPnlUnderlying, + baseUnrealizedPnlUsd + } = args + + const state: TPnlComputationState = { + unknownCostBasisValueUsd: unknownTransferInPnlMode === 'strict' ? currentUnknownUnderlying * tokenPrice : 0, + windfallPnlUsd: 0, + realizedPnlUnderlying: baseRealizedPnlUnderlying, + realizedPnlUsd: baseRealizedPnlUsd, + unrealizedPnlUnderlying: baseUnrealizedPnlUnderlying, + unrealizedPnlUsd: baseUnrealizedPnlUsd + } + + if (unknownTransferInPnlMode === 'strict') { + return state + } + + const unknownRealizedProceedsUnderlying = vault.unknownWithdrawalEntries.reduce( + (total, entry) => total + formatAmount(entry.proceedsAssets, metadata.token.decimals), + 0 + ) + const unknownRealizedProceedsUsd = vault.unknownWithdrawalEntries.reduce((total, entry) => { + const realizedTokenPrice = getTokenPriceForTimestamp(tokenPriceMap, entry.timestamp, tokenPrice) + return total + formatAmount(entry.proceedsAssets, metadata.token.decimals) * realizedTokenPrice + }, 0) + + if (unknownTransferInPnlMode === 'zero_basis') { + return { + ...state, + realizedPnlUnderlying: state.realizedPnlUnderlying + unknownRealizedProceedsUnderlying, + realizedPnlUsd: state.realizedPnlUsd + unknownRealizedProceedsUsd, + unrealizedPnlUnderlying: state.unrealizedPnlUnderlying + currentUnknownUnderlying, + unrealizedPnlUsd: state.unrealizedPnlUsd + currentUnknownUnderlying * tokenPrice + } + } + + const currentUnknownReceiptValueUsd = unknownLots.reduce((total, lot) => { + const receiptUnderlying = getEstimatedUnknownUnderlyingAtReceipt( + lot.shares, + lot.acquiredAt ?? currentTimestamp, + metadata.decimals, + ppsMap, + resolvedPricePerShare + ) + const receiptTokenPrice = getTokenPriceForTimestamp(tokenPriceMap, lot.acquiredAt ?? currentTimestamp, tokenPrice) + return total + receiptUnderlying * receiptTokenPrice + }, 0) + const realizedUnknownReceiptValueUsd = vault.unknownWithdrawalEntries.reduce((total, entry) => { + return ( + total + + entry.consumedLots.reduce((entryTotal, lot) => { + const receiptUnderlying = getEstimatedUnknownUnderlyingAtReceipt( + lot.shares, + lot.acquiredAt ?? entry.timestamp, + metadata.decimals, + ppsMap, + resolvedPricePerShare + ) + const receiptTokenPrice = getTokenPriceForTimestamp( + tokenPriceMap, + lot.acquiredAt ?? entry.timestamp, + tokenPrice + ) + return entryTotal + receiptUnderlying * receiptTokenPrice + }, 0) + ) + }, 0) + const unknownRealizedMarketPnlUsd = unknownRealizedProceedsUsd - realizedUnknownReceiptValueUsd + const unknownCurrentMarketPnlUsd = currentUnknownUnderlying * tokenPrice - currentUnknownReceiptValueUsd + + return { + ...state, + windfallPnlUsd: currentUnknownReceiptValueUsd + realizedUnknownReceiptValueUsd, + realizedPnlUnderlying: + state.realizedPnlUnderlying + (tokenPrice > 0 ? unknownRealizedMarketPnlUsd / tokenPrice : 0), + realizedPnlUsd: state.realizedPnlUsd + unknownRealizedMarketPnlUsd, + unrealizedPnlUnderlying: + state.unrealizedPnlUnderlying + (tokenPrice > 0 ? unknownCurrentMarketPnlUsd / tokenPrice : 0), + unrealizedPnlUsd: state.unrealizedPnlUsd + unknownCurrentMarketPnlUsd + } +} diff --git a/api/lib/holdings/services/ratelimit.ts b/api/lib/holdings/services/ratelimit.ts new file mode 100644 index 000000000..3544064bb --- /dev/null +++ b/api/lib/holdings/services/ratelimit.ts @@ -0,0 +1,53 @@ +import { getPool, isDatabaseEnabled } from '../db/connection' + +const WINDOW_MS = 60 * 1000 // 1 minute +const MAX_REQUESTS = 10 + +export interface RateLimitResult { + allowed: boolean + retryAfter?: number +} + +export async function checkRateLimit(ip: string): Promise { + if (!isDatabaseEnabled()) { + return { allowed: true } + } + + const pool = await getPool() + if (!pool) { + return { allowed: true } + } + + try { + const result = await pool.query<{ request_count: number; window_start: Date }>( + `INSERT INTO rate_limits (ip, request_count, window_start) + VALUES ($1, 1, NOW()) + ON CONFLICT (ip) DO UPDATE SET + request_count = CASE + WHEN rate_limits.window_start < NOW() - INTERVAL '1 minute' + THEN 1 + ELSE rate_limits.request_count + 1 + END, + window_start = CASE + WHEN rate_limits.window_start < NOW() - INTERVAL '1 minute' + THEN NOW() + ELSE rate_limits.window_start + END + RETURNING request_count, window_start`, + [ip] + ) + + const { request_count, window_start } = result.rows[0] + + if (request_count > MAX_REQUESTS) { + const windowEnd = new Date(window_start).getTime() + WINDOW_MS + const retryAfter = Math.max(1, Math.ceil((windowEnd - Date.now()) / 1000)) + return { allowed: false, retryAfter } + } + + return { allowed: true } + } catch (error) { + console.error('[RateLimit] Check failed:', error) + return { allowed: true } + } +} 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..8006039d0 --- /dev/null +++ b/api/lib/holdings/services/vaults.ts @@ -0,0 +1,564 @@ +import { config } 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 = `${config.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 = `${config.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/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 3bd6c1c01..648b05b57 100644 --- a/api/server.ts +++ b/api/server.ts @@ -5,6 +5,34 @@ import type { TTenderlyRevertRequest, TTenderlySnapshotRequest } from '../src/components/shared/types/tenderly' +import { + clearUserCache, + deleteStaleCache, + getHistoricalHoldingsChart, + getHoldingsActivity, + getHoldingsBreakdown, + getHoldingsPnL, + getHoldingsPnLDrilldown, + getHoldingsPnLSimple, + getHoldingsPnLSimpleHistory, + type HoldingsEventFetchType, + type HoldingsEventPaginationMode, + type HoldingsHistoryDenomination, + type HoldingsHistoryTimeframe, + initializeSchema, + isDatabaseEnabled, + type UnknownTransferInPnlMode, + 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 { buildTenderlyPanelStatus, buildTenderlyRevertResponse, @@ -88,6 +116,122 @@ 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 + }) +} + +function isValidAddress(address: string): boolean { + return /^0x[a-fA-F0-9]{40}$/.test(address) +} + +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 parseUnknownTransferInPnlMode(value: string | null): UnknownTransferInPnlMode { + return value === 'strict' || value === 'zero_basis' || value === 'windfall' ? value : 'windfall' +} + +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 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 @@ -304,6 +448,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 }) @@ -318,7 +463,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}` @@ -351,65 +496,843 @@ async function handleEnsoBalances(req: Request): Promise { } } -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 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 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) - } + const version: VaultVersion = versionParam === 'v2' || versionParam === 'v3' ? versionParam : 'all' - if (url.pathname === '/api/enso/route') { - return handleEnsoRoute(req) + try { + if (refresh) { + const cleared = await clearUserCache(address, version) + console.log(`[Server] Cleared ${cleared} cached entries for ${address}`) } - if (url.pathname === '/api/yvusd/aprs') { - return handleYvUsdAprs(req) - } + const holdings = await withHoldingsDebugContext( + createHoldingsDebugContext('history', address, debugEnabled, { + lotsEnabled: debugLotsEnabled, + vaultFilter: debugVault, + txFilter: debugTx + }), + async () => { + debugLog('route', 'started holdings history request', { + version, + fetchType, + paginationMode, + refresh, + debugLotsEnabled, + debugVault: debugVault?.toLowerCase() ?? null, + debugTx: debugTx?.toLowerCase() ?? null + }) - if (url.pathname === '/api/tenderly/status') { - return handleTenderlyStatus(req) + try { + const response = await getHistoricalHoldingsChart( + address, + version, + fetchType, + paginationMode, + denomination, + timeframe + ) + 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 + } + } + ) + + const hasHoldings = holdings.dataPoints.some((dp) => dp.value > 0) + if (!hasHoldings) { + return Response.json({ error: 'No holdings found for address', status: 404 }, { status: 404 }) } - if (url.pathname === '/api/tenderly/snapshot') { - const accessDeniedResponse = buildTenderlyAdminAccessDeniedResponse(server.requestIP(req)?.address) - if (accessDeniedResponse) { - return accessDeniedResponse + 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 handleTenderlySnapshot(req) + ) + } catch (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')) + + 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 activity = await getHoldingsActivity(address, version, limit, offset) + + return Response.json(activity, { + headers: { + 'Cache-Control': 'public, s-maxage=60, stale-while-revalidate=300' + } + }) + } 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 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 + } + } + ) + + 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 handleHoldingsPnL(req: Request): Promise { + const url = new URL(req.url) + const address = url.searchParams.get('address') + const versionParam = url.searchParams.get('version') + 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 unknownTransferInPnlMode = parseUnknownTransferInPnlMode(url.searchParams.get('unknownMode')) + const fetchType = parseHoldingsEventFetchType(url.searchParams.get('fetchType')) + const paginationMode = parseHoldingsEventPaginationMode(url.searchParams.get('paginationMode')) + + 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 pnl = await withHoldingsDebugContext( + createHoldingsDebugContext('pnl', address, debugEnabled, { + lotsEnabled: debugLotsEnabled, + vaultFilter: debugVault, + txFilter: debugTx + }), + async () => { + debugLog('route', 'started holdings pnl request', { + version, + unknownTransferInPnlMode, + fetchType, + paginationMode, + debugLotsEnabled, + debugVault: debugVault?.toLowerCase() ?? null, + debugTx: debugTx?.toLowerCase() ?? null + }) + + try { + const response = await getHoldingsPnL(address, version, unknownTransferInPnlMode, fetchType, paginationMode) + debugLog('route', 'completed holdings pnl request', { + version, + unknownTransferInPnlMode, + fetchType, + paginationMode, + totalVaults: response.summary.totalVaults, + totalCurrentValueUsd: response.summary.totalCurrentValueUsd, + totalPnlUsd: response.summary.totalPnlUsd, + totalEconomicGainUsd: response.summary.totalEconomicGainUsd + }) + return response + } catch (error) { + debugError('route', 'holdings pnl request failed', error, { + version, + unknownTransferInPnlMode, + fetchType, + paginationMode + }) + throw error + } + } + ) + + if (pnl.summary.totalVaults === 0) { + 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 + return Response.json(pnl, { + headers: { + 'Cache-Control': 'public, s-maxage=300, stale-while-revalidate=600' } - return handleTenderlyRevert(req) + }) + } catch (error) { + console.error('Error fetching holdings PnL:', 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 PnL', message, stack, status: 502 }, { status: 502 }) + } +} + +async function handleHoldingsPnLSimple(req: Request): Promise { + const url = new URL(req.url) + const address = url.searchParams.get('address') + const versionParam = url.searchParams.get('version') + 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')) + + 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 pnl = await withHoldingsDebugContext( + createHoldingsDebugContext('pnl-simple', address, debugEnabled, { + lotsEnabled: debugLotsEnabled, + vaultFilter: debugVault, + txFilter: debugTx + }), + async () => { + debugLog('route', 'started holdings simple pnl request', { + version, + fetchType, + paginationMode, + debugLotsEnabled, + debugVault: debugVault?.toLowerCase() ?? null, + debugTx: debugTx?.toLowerCase() ?? null + }) + + try { + const response = await getHoldingsPnLSimple(address, version, fetchType, paginationMode) + debugLog('route', 'completed holdings simple pnl request', { + version, + fetchType, + paginationMode, + totalVaults: response.summary.totalVaults, + baselineWeightUsd: response.summary.baselineWeightUsd, + growthWeightUsd: response.summary.growthWeightUsd, + protocolReturnPct: response.summary.protocolReturnPct + }) + return response + } catch (error) { + debugError('route', 'holdings simple pnl request failed', error, { + version, + fetchType, + paginationMode + }) + throw error + } + } + ) + + if (pnl.summary.totalVaults === 0) { + return Response.json({ error: 'No holdings found for address', status: 404 }, { status: 404 }) } - if (url.pathname === '/api/tenderly/increase-time') { - const accessDeniedResponse = buildTenderlyAdminAccessDeniedResponse(server.requestIP(req)?.address) - if (accessDeniedResponse) { - return accessDeniedResponse + return Response.json(pnl, { + headers: { + 'Cache-Control': 'public, s-maxage=300, stale-while-revalidate=600' + } + }) + } catch (error) { + console.error('Error fetching holdings simple PnL:', 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 simple PnL', message, stack, status: 502 }, { status: 502 }) + } +} + +async function handleHoldingsPnLSimpleHistory(req: Request): Promise { + const url = new URL(req.url) + const address = url.searchParams.get('address') + const chainIdParam = url.searchParams.get('chainId') + const versionParam = url.searchParams.get('version') + const vault = url.searchParams.get('vault') + 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')) + + 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 (vault !== null && !isValidAddress(vault)) { + return Response.json({ error: 'Invalid vault address', status: 400 }, { status: 400 }) + } + + if (vault !== null && (!chainIdParam || !Number.isInteger(Number(chainIdParam)))) { + return Response.json({ error: 'Missing or invalid chainId for vault filter', status: 400 }, { status: 400 }) + } + + const version: VaultVersion = versionParam === 'v2' || versionParam === 'v3' ? versionParam : 'all' + + try { + const history = await withHoldingsDebugContext( + createHoldingsDebugContext('pnl-simple-history', address, debugEnabled, { + lotsEnabled: debugLotsEnabled, + vaultFilter: debugVault, + txFilter: debugTx + }), + async () => { + debugLog('route', 'started holdings simple pnl history request', { + version, + timeframe, + vault: vault?.toLowerCase() ?? null, + chainId: vault !== null ? Number(chainIdParam) : null, + fetchType, + paginationMode, + debugLotsEnabled, + debugVault: debugVault?.toLowerCase() ?? null, + debugTx: debugTx?.toLowerCase() ?? null + }) + + try { + const response = await getHoldingsPnLSimpleHistory( + address, + version, + fetchType, + paginationMode, + timeframe, + vault ?? undefined, + vault !== null ? Number(chainIdParam) : undefined + ) + debugLog('route', 'completed holdings simple pnl history request', { + version, + timeframe, + vault: vault?.toLowerCase() ?? null, + chainId: vault !== null ? Number(chainIdParam) : null, + fetchType, + paginationMode, + totalVaults: response.summary.totalVaults, + points: response.dataPoints.length + }) + return response + } catch (error) { + debugError('route', 'holdings simple pnl history request failed', error, { + version, + timeframe, + vault: vault?.toLowerCase() ?? null, + chainId: vault !== null ? Number(chainIdParam) : null, + fetchType, + paginationMode + }) + throw error + } } - return handleTenderlyIncreaseTime(req) + ) + + if (history.summary.totalVaults === 0) { + return Response.json({ error: 'No holdings found for address', status: 404 }, { status: 404 }) } - if (url.pathname === '/api/tenderly/fund') { - const accessDeniedResponse = buildTenderlyAdminAccessDeniedResponse(server.requestIP(req)?.address) - if (accessDeniedResponse) { - return accessDeniedResponse + return Response.json(history, { + headers: { + 'Cache-Control': 'public, s-maxage=300, stale-while-revalidate=600' + } + }) + } catch (error) { + console.error('Error fetching holdings simple PnL 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 simple PnL history', message, stack, status: 502 }, + { status: 502 } + ) + } +} + +async function handleHoldingsPnLDrilldown(req: Request): Promise { + const url = new URL(req.url) + const address = url.searchParams.get('address') + const vault = url.searchParams.get('vault') + const versionParam = url.searchParams.get('version') + 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') ?? vault + const debugTx = url.searchParams.get('debugTx') + const unknownTransferInPnlMode = parseUnknownTransferInPnlMode(url.searchParams.get('unknownMode')) + const fetchType = parseHoldingsEventFetchType(url.searchParams.get('fetchType')) + const paginationMode = parseHoldingsEventPaginationMode(url.searchParams.get('paginationMode')) + + 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 (vault !== null && !isValidAddress(vault)) { + return Response.json({ error: 'Invalid vault address', status: 400 }, { status: 400 }) + } + + const version: VaultVersion = versionParam === 'v2' || versionParam === 'v3' ? versionParam : 'all' + + try { + const pnl = await withHoldingsDebugContext( + createHoldingsDebugContext('pnl', address, debugEnabled, { + lotsEnabled: debugLotsEnabled, + vaultFilter: debugVault, + txFilter: debugTx + }), + async () => { + debugLog('route', 'started holdings pnl drilldown request', { + version, + unknownTransferInPnlMode, + fetchType, + paginationMode, + vault: vault?.toLowerCase() ?? null, + debugLotsEnabled, + debugVault: debugVault?.toLowerCase() ?? null, + debugTx: debugTx?.toLowerCase() ?? null + }) + + try { + const response = await getHoldingsPnLDrilldown( + address, + version, + unknownTransferInPnlMode, + fetchType, + paginationMode, + vault + ) + debugLog('route', 'completed holdings pnl drilldown request', { + version, + unknownTransferInPnlMode, + fetchType, + paginationMode, + vault: vault?.toLowerCase() ?? null, + totalVaults: response.summary.totalVaults, + totalCurrentValueUsd: response.summary.totalCurrentValueUsd, + totalPnlUsd: response.summary.totalPnlUsd, + totalEconomicGainUsd: response.summary.totalEconomicGainUsd + }) + return response + } catch (error) { + debugError('route', 'holdings pnl drilldown request failed', error, { + version, + unknownTransferInPnlMode, + fetchType, + paginationMode, + vault: vault?.toLowerCase() ?? null + }) + throw error + } } - return handleTenderlyFund(req) + ) + + if (pnl.summary.totalVaults === 0) { + return Response.json( + { + error: vault ? 'No matching holdings found for address and vault' : 'No holdings found for address', + status: 404 + }, + { status: 404 } + ) } - return new Response('Not found', { status: 404 }) - }, - port: API_SERVER_PORT -}) + return Response.json(pnl, { + headers: { + 'Cache-Control': 'public, s-maxage=300, stale-while-revalidate=600' + } + }) + } catch (error) { + console.error('Error fetching holdings PnL drilldown:', 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 PnL drilldown', message, stack, status: 502 }, + { status: 502 } + ) + } +} + +async function handleHoldingsChores(req: Request): Promise { + if (req.method !== 'POST') { + return Response.json({ error: 'Method not allowed' }, { status: 405 }) + } + + const authHeader = req.headers.get('authorization') + const cronSecret = process.env.CRON_SECRET -console.log(`🚀 API server running on http://localhost:${API_SERVER_PORT}`) + if (!cronSecret) { + console.error('[Chores] CRON_SECRET not configured') + return Response.json({ error: 'Server misconfigured' }, { status: 500 }) + } + + if (authHeader !== `Bearer ${cronSecret}`) { + return Response.json({ error: 'Unauthorized' }, { status: 401 }) + } + + try { + await initializeSchema() + const deletedCount = await deleteStaleCache() + + return Response.json({ + success: true, + deletedRows: deletedCount, + timestamp: new Date().toISOString() + }) + } catch (error) { + console.error('[Chores] Failed to run cleanup:', error) + return Response.json({ error: 'Cleanup failed' }, { status: 500 }) + } +} + +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 (!isDatabaseEnabled()) { + return Response.json({ error: 'Caching not enabled (DATABASE_URL 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 { + const vaults: VaultIdentifier[] = body.vaults.map((vault) => ({ + address: vault.address, + chainId: vault.chainId + })) + + 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 initializeSchema() + + 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/activity') { + return withCors(await handleHoldingsActivity(req)) + } + + if (url.pathname === '/api/holdings/breakdown') { + return withCors(await handleHoldingsBreakdown(req)) + } + + if (url.pathname === '/api/holdings/pnl/drilldown') { + return withCors(await handleHoldingsPnLDrilldown(req)) + } + + if (url.pathname === '/api/holdings/pnl/simple') { + return withCors(await handleHoldingsPnLSimple(req)) + } + + if (url.pathname === '/api/holdings/pnl/simple-history') { + return withCors(await handleHoldingsPnLSimpleHistory(req)) + } + + if (url.pathname === '/api/holdings/pnl') { + return withCors(await handleHoldingsPnL(req)) + } + + if (url.pathname === '/api/holdings/chores') { + return withCors(await handleHoldingsChores(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/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: 3001, + idleTimeout: 120 + }) + + console.log('🚀 API server running on http://localhost:3001') + console.log('📊 Holdings API: http://localhost:3001/api/holdings/history?address=0x...') + console.log('🗂️ Holdings Activity API: http://localhost:3001/api/holdings/activity?address=0x...') + console.log('🧩 Holdings Breakdown API: http://localhost:3001/api/holdings/breakdown?address=0x...') + console.log('💹 PnL API: http://localhost:3001/api/holdings/pnl?address=0x...') + console.log('📈 Simple PnL API: http://localhost:3001/api/holdings/pnl/simple?address=0x...') + console.log('📊 Simple PnL History API: http://localhost:3001/api/holdings/pnl/simple-history?address=0x...') + console.log('🧾 PnL Drilldown API: http://localhost:3001/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 1222e316f..54e7a3ec6 100644 --- a/bun.lock +++ b/bun.lock @@ -6,6 +6,7 @@ "name": "yearnfi", "dependencies": { "@headlessui/react": "2.2.9", + "@neondatabase/serverless": "^1.0.2", "@plausible-analytics/tracker": "^0.4.4", "@rainbow-me/rainbowkit": "2.2.10", "@react-hookz/web": "24.0.4", @@ -34,6 +35,7 @@ "@testing-library/react": "16.3.2", "@types/minimatch": "6.0.0", "@types/node": "25.3.0", + "@types/pg": "^8.16.0", "@types/react": "19.2.14", "@types/react-dom": "19.2.3", "@vercel/node": "5.6.3", @@ -79,15 +81,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.2", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="], "@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=="], @@ -119,7 +121,7 @@ "@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=="], @@ -255,15 +257,15 @@ "@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=="], @@ -319,7 +321,9 @@ "@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=="], + + "@neondatabase/serverless": ["@neondatabase/serverless@1.0.2", "", { "dependencies": { "@types/node": "^22.15.30", "@types/pg": "^8.8.0" } }, "sha512-I5sbpSIAHiB+b6UttofhrN/UJXII+4tZPAq1qugzwCwLIL8EZLV7F/JyHUrEIiGgQpEXzpnjlJ+zwcEhheGvCw=="], "@noble/ciphers": ["@noble/ciphers@1.3.0", "", {}, "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw=="], @@ -373,13 +377,13 @@ "@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=="], + "@react-aria/focus": ["@react-aria/focus@3.21.5", "", { "dependencies": { "@react-aria/interactions": "^3.27.1", "@react-aria/utils": "^3.33.1", "@react-types/shared": "^3.33.1", "@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-V18fwCyf8zqgJdpLQeDU5ZRNd9TeOfBbhLgmX77Zr5ae9XwaoJ1R3SFJG1wCJX60t34AW+aLZSEEK+saQElf3Q=="], - "@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/interactions": ["@react-aria/interactions@3.27.1", "", { "dependencies": { "@react-aria/ssr": "^3.9.10", "@react-aria/utils": "^3.33.1", "@react-stately/flags": "^3.1.2", "@react-types/shared": "^3.33.1", "@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-M3wLpTTmDflI0QGNK0PJNUaBXXfeBXue8ZxLMngfc1piHNiH4G5lUvWd9W14XVbqrSCVY8i8DfGrNYpyyZu0tw=="], "@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/utils": ["@react-aria/utils@3.33.1", "", { "dependencies": { "@react-aria/ssr": "^3.9.10", "@react-stately/flags": "^3.1.2", "@react-stately/utils": "^3.11.0", "@react-types/shared": "^3.33.1", "@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-kIx1Sj6bbAT0pdqCegHuPanR9zrLn5zMRiM7LN12rgRf55S19ptd9g3ncahArifYTRkfEU9VIn+q0HjfMqS9/w=="], "@react-hookz/deep-equal": ["@react-hookz/deep-equal@1.0.4", "", {}, "sha512-N56fTrAPUDz/R423pag+n6TXWbvlBZDtTehaGFjK0InmN+V2OFWLE/WmORhmn6Ce7dlwH5+tQN1LJFw3ngTJVg=="], @@ -389,7 +393,7 @@ "@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.33.1", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-oJHtjvLG43VjwemQDadlR5g/8VepK56B/xKO2XORPHt9zlW6IZs3tZrYlvH29BMvoqC7RtE7E5UjgbnbFtDGag=="], "@renovatebot/pep440": ["@renovatebot/pep440@4.2.1", "", {}, "sha512-2FK1hF93Fuf1laSdfiEmJvSJPVIDHEUTz68D3Fi9s0IZrrpaEcj6pTFBTbYvsgC5du4ogrtf5re7yMMvrKNgkw=="], @@ -415,55 +419,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.1", "", { "os": "android", "cpu": "arm" }, "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA=="], - "@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.1", "", { "os": "android", "cpu": "arm64" }, "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA=="], - "@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.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw=="], - "@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.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew=="], - "@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.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w=="], - "@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.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g=="], - "@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.1", "", { "os": "linux", "cpu": "arm" }, "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g=="], - "@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.1", "", { "os": "linux", "cpu": "arm" }, "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg=="], - "@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.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ=="], - "@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.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA=="], - "@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.1", "", { "os": "linux", "cpu": "none" }, "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ=="], - "@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.1", "", { "os": "linux", "cpu": "none" }, "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw=="], - "@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.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw=="], - "@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.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg=="], - "@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.1", "", { "os": "linux", "cpu": "none" }, "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg=="], - "@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.1", "", { "os": "linux", "cpu": "none" }, "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg=="], - "@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.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ=="], - "@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.1", "", { "os": "linux", "cpu": "x64" }, "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg=="], - "@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.1", "", { "os": "linux", "cpu": "x64" }, "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w=="], - "@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.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw=="], - "@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.1", "", { "os": "none", "cpu": "arm64" }, "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA=="], - "@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.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g=="], - "@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.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg=="], - "@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.1", "", { "os": "win32", "cpu": "x64" }, "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg=="], - "@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.1", "", { "os": "win32", "cpu": "x64" }, "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ=="], "@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=="], @@ -481,7 +485,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=="], @@ -555,7 +559,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=="], @@ -575,6 +579,8 @@ "@types/node": ["@types/node@25.3.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A=="], + "@types/pg": ["@types/pg@8.20.0", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^2.2.0" } }, "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow=="], + "@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="], "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], @@ -715,7 +721,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.16", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-Lyf3aK28zpsD1yQMiiHD4RvVb6UdMoo8xzG2XzFIfR9luPzOpcBlAsT/qfB1XWS1bxWT+UtE4WmQgsp297FYOA=="], "bech32": ["bech32@1.1.4", "", {}, "sha512-s0IrSOzLlbvX7yp4WBfPITzpAU8sqQcpsmwXDiKwrG4r491vwCO/XpejasRNl0piBMe/DvP4Tz0mIS/X1DPJBQ=="], @@ -727,13 +733,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.5", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ=="], "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=="], @@ -753,7 +759,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.30001786", "", {}, "sha512-4oxTZEvqmLLrERwxO76yfKM7acZo310U+v4kqexI2TL1DkkUEMT8UijrxxcnVdxR3qkVf5awGRX+4Z6aPHVKrA=="], "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=="], @@ -781,7 +787,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=="], @@ -813,7 +819,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=="], @@ -869,7 +875,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=="], @@ -879,7 +885,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.6", "", {}, "sha512-f8mefEW4WIVg4LckePx3mALjQSPQgFlg9U8yaPdlsbdYcHQyj9n2zL2LJEA52smeYxOvmd/nB7TpMtHGMTHcug=="], "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], @@ -907,11 +913,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.331", "", {}, "sha512-IbxXrsTlD3hRodkLnbxAPP4OuJYdWCeM3IOdT+CpcMoIwIoDfCmRpEtSPfwBXxVkg9xmBeY7Lz2Eo2TDn/HC3Q=="], "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=="], @@ -925,7 +931,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.20.1", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA=="], "environment": ["environment@1.1.0", "", {}, "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q=="], @@ -1023,7 +1029,7 @@ "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.13.7", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q=="], "glob": ["glob@13.0.6", "", { "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", "path-scurry": "^2.0.2" } }, "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw=="], @@ -1037,7 +1043,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=="], @@ -1057,7 +1063,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.11", "", {}, "sha512-r4xbIa3mGGGoH9nN4A14DOg2wx7y2oQyJEb5O57C/xzETG/qx4c7CVDQ5WMeKHZ7ORk2W0hZ/sQKXTav3cmYBA=="], "html-url-attributes": ["html-url-attributes@3.0.1", "", {}, "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ=="], @@ -1179,7 +1185,7 @@ "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=="], @@ -1277,7 +1283,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=="], @@ -1287,7 +1293,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=="], @@ -1301,7 +1307,7 @@ "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=="], @@ -1315,7 +1321,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.37", "", {}, "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg=="], "nopt": ["nopt@8.1.0", "", { "dependencies": { "abbrev": "^3.0.0" }, "bin": { "nopt": "bin/nopt.js" } }, "sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A=="], @@ -1369,9 +1375,15 @@ "pathval": ["pathval@1.1.1", "", {}, "sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ=="], + "pg-int8": ["pg-int8@1.0.1", "", {}, "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw=="], + + "pg-protocol": ["pg-protocol@1.13.0", "", {}, "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w=="], + + "pg-types": ["pg-types@2.2.0", "", { "dependencies": { "pg-int8": "1.0.1", "postgres-array": "~2.0.0", "postgres-bytea": "~1.0.0", "postgres-date": "~1.0.4", "postgres-interval": "^1.1.0" } }, "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA=="], + "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=="], @@ -1397,6 +1409,14 @@ "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=="], + "postgres-array": ["postgres-array@2.0.0", "", {}, "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="], + + "postgres-bytea": ["postgres-bytea@1.0.1", "", {}, "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ=="], + + "postgres-date": ["postgres-date@1.0.7", "", {}, "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q=="], + + "postgres-interval": ["postgres-interval@1.2.0", "", { "dependencies": { "xtend": "^4.0.0" } }, "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ=="], + "preact": ["preact@10.24.2", "", {}, "sha512-1cSoF0aCC8uaARATfrlz4VCBqE8LwZwRfLgkxJOQwAlQt6ayTmi0D9OF7nXid1POI5SZidFuG9CnlXbDfLqY/Q=="], "pretty-format": ["pretty-format@27.5.1", "", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="], @@ -1413,11 +1433,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.5.5", "", {}, "sha512-iQBvKj7MRKO+co+MY0IZpyLO+ezvttxsmV86WywrgPuAmgBkv0pytyi03wourniSoPgzffeBW6cBgIkpqcvjTg=="], "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=="], @@ -1485,7 +1505,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.1", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.60.1", "@rollup/rollup-android-arm64": "4.60.1", "@rollup/rollup-darwin-arm64": "4.60.1", "@rollup/rollup-darwin-x64": "4.60.1", "@rollup/rollup-freebsd-arm64": "4.60.1", "@rollup/rollup-freebsd-x64": "4.60.1", "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", "@rollup/rollup-linux-arm-musleabihf": "4.60.1", "@rollup/rollup-linux-arm64-gnu": "4.60.1", "@rollup/rollup-linux-arm64-musl": "4.60.1", "@rollup/rollup-linux-loong64-gnu": "4.60.1", "@rollup/rollup-linux-loong64-musl": "4.60.1", "@rollup/rollup-linux-ppc64-gnu": "4.60.1", "@rollup/rollup-linux-ppc64-musl": "4.60.1", "@rollup/rollup-linux-riscv64-gnu": "4.60.1", "@rollup/rollup-linux-riscv64-musl": "4.60.1", "@rollup/rollup-linux-s390x-gnu": "4.60.1", "@rollup/rollup-linux-x64-gnu": "4.60.1", "@rollup/rollup-linux-x64-musl": "4.60.1", "@rollup/rollup-openbsd-x64": "4.60.1", "@rollup/rollup-openharmony-arm64": "4.60.1", "@rollup/rollup-win32-arm64-msvc": "4.60.1", "@rollup/rollup-win32-ia32-msvc": "4.60.1", "@rollup/rollup-win32-x64-gnu": "4.60.1", "@rollup/rollup-win32-x64-msvc": "4.60.1", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w=="], "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="], @@ -1521,13 +1541,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=="], @@ -1573,9 +1593,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.2", "", {}, "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA=="], - "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.13", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-tOG/7GyXpFevhXVh8jOPJrmtRpOTsYqUIkVdVooZYJS/z8WhfQUX8RJILmeuJNinGAMSu1veBr4asSHFt5/hng=="], "thread-stream": ["thread-stream@0.15.2", "", { "dependencies": { "real-require": "^0.1.0" } }, "sha512-UkEhKIg2pD+fjkHQKyJO3yoIvAP3N6RlNFt2dUhcS1FGvCD1cQa1M/PGknCLFIyZdtJOWQjejp7bdNqmN7zwdA=="], @@ -1643,7 +1663,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=="], @@ -1713,7 +1733,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.8.3", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg=="], "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=="], @@ -1783,6 +1803,10 @@ "@metamask/utils/uuid": ["uuid@9.0.1", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA=="], + "@neondatabase/serverless/@types/node": ["@types/node@22.19.17", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q=="], + + "@radix-ui/react-dialog/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=="], + "@reown/appkit/@walletconnect/types": ["@walletconnect/types@2.21.0", "", { "dependencies": { "@walletconnect/events": "1.0.1", "@walletconnect/heartbeat": "1.2.2", "@walletconnect/jsonrpc-types": "1.0.4", "@walletconnect/keyvaluestorage": "1.1.1", "@walletconnect/logger": "2.1.2", "events": "3.3.0" } }, "sha512-ll+9upzqt95ZBWcfkOszXZkfnpbJJ2CmxMfGgE5GmhdxxxCcO5bGhXkI+x8OpiS555RJ/v/sXJYMSOLkmu4fFw=="], "@reown/appkit/@walletconnect/universal-provider": ["@walletconnect/universal-provider@2.21.0", "", { "dependencies": { "@walletconnect/events": "1.0.1", "@walletconnect/jsonrpc-http-connection": "1.0.8", "@walletconnect/jsonrpc-provider": "1.0.14", "@walletconnect/jsonrpc-types": "1.0.4", "@walletconnect/jsonrpc-utils": "1.0.8", "@walletconnect/keyvaluestorage": "1.1.1", "@walletconnect/logger": "2.1.2", "@walletconnect/sign-client": "2.21.0", "@walletconnect/types": "2.21.0", "@walletconnect/utils": "2.21.0", "es-toolkit": "1.33.0", "events": "3.3.0" } }, "sha512-mtUQvewt+X0VBQay/xOJBvxsB3Xsm1lTwFjZ6WUwSOTR1X+FNb71hSApnV5kbsdDIpYPXeQUbGt2se1n5E5UBg=="], @@ -1793,13 +1817,13 @@ "@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=="], + "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.9.2", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" }, "bundled": true }, "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA=="], - "@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/runtime": ["@emnapi/runtime@1.9.2", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw=="], - "@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/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="], - "@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/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.2", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" }, "bundled": true }, "sha512-sNXv5oLJ7ob93xkZ1XnxisYhGYXfaG9f65/ZgYuAu3qt7b3NadcOEhLvx28hv31PgX8SZJRYrAIPQilQmFpLVw=="], "@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], @@ -1853,13 +1877,13 @@ "@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.1", "", {}, "sha512-gQCLc/vWroE8lIpleXtdJhTFDogTdZG9AjMUpVkDf2iTCNwYNWA+u16dL41TqUDJO4gm2IgrcMv3uTpjd4Pwmg=="], "chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], @@ -1895,11 +1919,13 @@ "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=="], @@ -1913,7 +1939,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.3.1", "", {}, "sha512-Y71HWT4hydF1IAG/2OPync4dgQ/J2iWye7eg6CuzJHI+E97tvqFPlADzxiNnjH6WSljg8ecfXMr9k6bfFuqA5w=="], "pkg-types/pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], @@ -1921,7 +1947,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.12", "", { "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-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g=="], "pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], @@ -1941,7 +1967,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.3.1", "", {}, "sha512-Y71HWT4hydF1IAG/2OPync4dgQ/J2iWye7eg6CuzJHI+E97tvqFPlADzxiNnjH6WSljg8ecfXMr9k6bfFuqA5w=="], "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=="], @@ -1955,12 +1981,16 @@ "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=="], @@ -1989,6 +2019,8 @@ "@metamask/sdk/debug/ms": ["ms@2.1.2", "", {}, "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="], + "@neondatabase/serverless/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + "@reown/appkit-controllers/@walletconnect/universal-provider/@walletconnect/sign-client": ["@walletconnect/sign-client@2.21.0", "", { "dependencies": { "@walletconnect/core": "2.21.0", "@walletconnect/events": "1.0.1", "@walletconnect/heartbeat": "1.2.2", "@walletconnect/jsonrpc-utils": "1.0.8", "@walletconnect/logger": "2.1.2", "@walletconnect/time": "1.0.2", "@walletconnect/types": "2.21.0", "@walletconnect/utils": "2.21.0", "events": "3.3.0" } }, "sha512-z7h+PeLa5Au2R591d/8ZlziE0stJvdzP9jNFzFolf2RG/OiXulgFKum8PrIyXy+Rg2q95U9nRVUF9fWcn78yBA=="], "@reown/appkit-controllers/@walletconnect/universal-provider/@walletconnect/types": ["@walletconnect/types@2.21.0", "", { "dependencies": { "@walletconnect/events": "1.0.1", "@walletconnect/heartbeat": "1.2.2", "@walletconnect/jsonrpc-types": "1.0.4", "@walletconnect/keyvaluestorage": "1.1.1", "@walletconnect/logger": "2.1.2", "events": "3.3.0" } }, "sha512-ll+9upzqt95ZBWcfkOszXZkfnpbJJ2CmxMfGgE5GmhdxxxCcO5bGhXkI+x8OpiS555RJ/v/sXJYMSOLkmu4fFw=="], @@ -2005,7 +2037,7 @@ "@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.13", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w=="], "@vercel/node/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="], @@ -2027,7 +2059,7 @@ "@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=="], @@ -2035,6 +2067,10 @@ "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=="], @@ -2103,7 +2139,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=="], @@ -2251,7 +2287,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=="], @@ -2263,7 +2301,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=="], @@ -2275,7 +2313,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=="], @@ -2286,5 +2324,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/plans/2026-04-20-growth-chart-numeraire-spec.md b/docs/plans/2026-04-20-growth-chart-numeraire-spec.md new file mode 100644 index 000000000..945ee8490 --- /dev/null +++ b/docs/plans/2026-04-20-growth-chart-numeraire-spec.md @@ -0,0 +1,369 @@ +# Growth Chart Numeraire Spec + +Date: 2026-04-20 + +## Problem + +The current `Growth` chart is aggregated in receipt-time USD. + +That works well when a wallet is effectively all stablecoins, because the displayed unit matches the user's mental model. + +It works less well for mixed wallets: + +- if the wallet is mostly ETH-like, a USD growth line is technically valid but not always intuitive +- if the wallet contains multiple unrelated assets, there is no honest single "native underlying growth" unit +- `Cumulative %` and `Annualized %` are useful rate charts, but they are not a substitute for "growth in a chosen denomination" + +The product needs a more robust default for mixed wallets without pretending that mixed underlyings can be summed natively. + +## Goals + +1. Make the `Growth` chart choose a better default display mode based on the wallet's open-position composition. +2. Keep a single simple chart surface for users: + - default intelligently + - allow explicit override +3. Preserve the existing simple protocol-return framing: + - protocol growth only + - asset price moves excluded after receipt +4. Support mixed wallets honestly by defaulting to `Index` when no single denomination dominates. + +## Non-Goals + +1. Do not create a fake aggregate chart in "native underlying units" for mixed wallets. +2. Do not stack raw native units from different assets on the same y-axis. +3. Do not introduce full mark-to-market PnL into this chart. +4. Do not implement arbitrary custom-asset denomination in v1. + +## Product Decision + +The `Growth` chart becomes a **display-mode chart** with a selector: + +- `Auto` +- `Index` +- `USD` +- `ETH` + +`Auto` resolves to one of the other three modes based on the wallet's current open-position mix. + +### Auto Resolution + +Use the wallet's **current open lots only**, not historical receipts and not already exited positions. + +Use **open baseline weight in receipt-time USD** as the composition measure. + +This aligns with the simple-return accounting model and avoids introducing current spot-price dependency just to choose a default display mode. + +Auto resolves as: + +- `USD` if stable open baseline weight is `>= 90%` +- `ETH` if ETH-family open baseline weight is `>= 90%` +- `Index` otherwise + +### Why Open Baseline Weight + +Open baseline weight is the cleanest measure for this purpose because: + +- it represents capital currently at work +- it ignores already exited positions +- it stays aligned with the simple-return lot model +- it does not require an extra mark-to-market pricing layer + +## UI Behavior + +### Top-Level Tabs + +Replace the current top-level chart tabs: + +- `Balance` +- `Growth` +- `Cumulative %` +- `Annualized %` +- `Growth Index` + +with: + +- `Balance` +- `Growth` +- `Cumulative %` +- `Annualized %` + +`Growth Index` becomes one display mode inside `Growth`, not a separate top-level tab. + +### Growth Display Selector + +When the active tab is `Growth`, show: + +- `Auto` +- `Index` +- `USD` +- `ETH` + +The selector should match the existing vault-page selector styling. + +### Auto Copy + +When `Auto` is selected, show a small explanatory line under the chart title: + +- `Auto: stable-dominant portfolio, showing USD` +- `Auto: ETH-dominant portfolio, showing ETH` +- `Auto: mixed portfolio, showing Index` + +### Rendering Behavior + +- `Index` + - render the current aggregate wallet line + - render the existing comparison family lines +- `USD` + - render a single aggregate growth line +- `ETH` + - render a single aggregate growth line + +Family comparison lines remain index-only in v1. This keeps the first version clear and avoids overloading the chart. + +## Metric Definitions + +### 1. Growth in USD + +This is the existing `growthWeightUsd`. + +For each lot: + +```text +growthWeightUsd = growthUnderlying * receiptPriceUsd +``` + +Aggregate wallet growth is: + +```text +walletGrowthUsd = sum(growthWeightUsd across all vault families) +``` + +This remains the definition of `Growth` when display mode is `USD`. + +### 2. Growth in ETH + +Add an ETH-equivalent growth series using receipt-time ETH conversion. + +For each lot: + +```text +receiptPriceEth = receiptPriceUsd / receiptEthPriceUsd +growthWeightEth = growthUnderlying * receiptPriceEth +``` + +Aggregate wallet growth is: + +```text +walletGrowthEth = sum(growthWeightEth across all vault families) +``` + +Important: + +- this is **ETH-equivalent protocol growth** +- it is not current mark-to-market ETH value +- it still excludes later asset price moves by using receipt-time conversion + +### 3. Growth Index + +Keep the current flow-neutral index logic. + +Per interval: + +```text +deltaGrowth = growthWeightUsd[t] - growthWeightUsd[t-1] +deltaExposureYears = exposureYears[t] - exposureYears[t-1] +intervalYears = deltaSeconds / secondsPerYear + +intervalReturn = deltaGrowth * intervalYears / deltaExposureYears +nextIndex = previousIndex * (1 + intervalReturn) +``` + +The index: + +- starts at `100` +- compounds through time +- does not step down just because a new position opens + +## Classification Rules + +### Stable + +Use the existing vault metadata `category === 'stable'`. + +### ETH-Family + +Add a new backend classifier for ETH-like underlyings using a maintained allowlist. + +The v1 allowlist should match normalized token symbol and optionally token address where needed. + +Seed list: + +- `ETH` +- `WETH` +- `STETH` +- `WSTETH` +- `RETH` +- `FRXETH` +- `SFRXETH` +- `EETH` +- `WEETH` +- `EZETH` +- `METH` +- `MSETH` + +Classification order: + +1. `stable` +2. `eth_family` +3. `other` + +This keeps stablecoin vaults from being mislabeled by symbol collisions. + +## Backend Changes + +### 1. Extend Simple History Response + +Extend `HoldingsPnLSimpleHistoryPoint` with: + +- `growthWeightEth: number | null` + +Extend the history response summary with: + +- `recommendedGrowthDisplay: 'usd' | 'eth' | 'index'` +- `recommendedGrowthDisplayReason: 'stable_dominant' | 'eth_dominant' | 'mixed'` +- `openBaselineCompositionUsd` + - `stable` + - `ethFamily` + - `other` + +### 2. Compute ETH-Equivalent Growth + +Fetch receipt-time ETH/USD prices alongside receipt-time token prices. + +The simplest path is to treat mainnet WETH as the ETH numeraire source and fetch the same receipt buckets already used for token prices. + +For each lot: + +- compute `receiptPriceEth` +- carry realized and unrealized ETH-equivalent growth in parallel to USD-equivalent growth + +### 3. Compute Recommended Growth Display + +At the latest timestamp: + +- inspect open lots only +- group open baseline weight into: + - stable + - ethFamily + - other + +Then resolve the recommended display mode using the `90%` thresholds above. + +### 4. Keep Index Logic Unchanged + +The current `growthIndex` logic already solves the mixed-wallet default problem well. + +Do not change its math in this spec. + +## Frontend Changes + +### 1. Replace Growth Index Tab + +Remove the separate `Growth Index` top-level tab. + +Move that chart renderer under `Growth` mode = `Index`. + +### 2. Add Growth Display Mode State + +Add frontend state: + +- `growthDisplayMode: 'auto' | 'index' | 'usd' | 'eth'` + +When `auto` is selected: + +- resolve the effective display mode from the backend recommendation +- show the explanatory helper copy + +### 3. Render By Effective Mode + +When active tab is `Growth`: + +- `effectiveMode === 'index'` + - render the current index chart +- `effectiveMode === 'usd'` + - render aggregate `growthWeightUsd` +- `effectiveMode === 'eth'` + - render aggregate `growthWeightEth` + +### 4. Keep Balance Selector Separate + +The existing `Balance` tab denomination selector remains separate. + +`Balance` and `Growth` should not share the same denomination state because they answer different questions. + +## API Shape Sketch + +```ts +type TRecommendedGrowthDisplay = 'usd' | 'eth' | 'index' +type TRecommendedGrowthDisplayReason = 'stable_dominant' | 'eth_dominant' | 'mixed' + +type TProtocolReturnHistoryPoint = { + date: string + growthWeightUsd: number + growthWeightEth: number | null + protocolReturnPct: number | null + annualizedProtocolReturnPct: number | null + growthIndex: number | null +} + +type TProtocolReturnHistorySummary = { + recommendedGrowthDisplay: TRecommendedGrowthDisplay + recommendedGrowthDisplayReason: TRecommendedGrowthDisplayReason + openBaselineCompositionUsd: { + stable: number + ethFamily: number + other: number + } +} +``` + +## Acceptance Criteria + +1. Stable-dominant wallets open `Growth` in `Auto -> USD`. +2. ETH-dominant wallets open `Growth` in `Auto -> ETH`. +3. Mixed wallets open `Growth` in `Auto -> Index`. +4. Switching between `Auto`, `Index`, `USD`, and `ETH` does not refetch unrelated chart data. +5. `Index` mode preserves the current wallet + family-line comparison behavior. +6. `USD` and `ETH` modes render aggregate growth without fake jumps from deposits. +7. The chart tooltip and subtitle clearly state which display mode is active. + +## Future Extensions + +### 1. Custom Asset Denominations + +The ETH-equivalent logic generalizes naturally: + +```text +receiptPriceNumeraire = receiptPriceUsd / receiptNumeraireUsd +growthWeightNumeraire = growthUnderlying * receiptPriceNumeraire +``` + +Once the backend can fetch historical prices for an arbitrary supported numeraire token, the selector can be extended beyond `ETH`. + +This is out of scope for v1. + +### 2. Contribution View + +Add a separate `Contribution` chart later: + +- stacked by vault family +- denominated in the selected numeraire +- valid only because all series are converted into a common numeraire first + +This should not be confused with stacking native units. + +### 3. Threshold Tuning + +The `90%` dominance threshold is intentionally conservative. + +If user testing suggests wallets with `80-90%` dominance still feel obviously stable- or ETH-denominated, the threshold can be lowered later without changing the overall design. diff --git a/docs/simple-protocol-return-annualized-return-chart.md b/docs/simple-protocol-return-annualized-return-chart.md new file mode 100644 index 000000000..bd54c79db --- /dev/null +++ b/docs/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/simple-protocol-return-cumulative-return-chart.md b/docs/simple-protocol-return-cumulative-return-chart.md new file mode 100644 index 000000000..d0688daaa --- /dev/null +++ b/docs/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/simple-protocol-return-growth-chart.md b/docs/simple-protocol-return-growth-chart.md new file mode 100644 index 000000000..c90298668 --- /dev/null +++ b/docs/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/simple-protocol-return-growth-index-chart.md b/docs/simple-protocol-return-growth-index-chart.md new file mode 100644 index 000000000..f8eb51739 --- /dev/null +++ b/docs/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 155559f30..a6bba9ae5 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ }, "dependencies": { "@headlessui/react": "2.2.9", + "@neondatabase/serverless": "^1.0.2", "@plausible-analytics/tracker": "^0.4.4", "@rainbow-me/rainbowkit": "2.2.10", "@react-hookz/web": "24.0.4", @@ -48,6 +49,7 @@ "@tailwindcss/postcss": "4.1.18", "@testing-library/react": "16.3.2", "@types/minimatch": "6.0.0", + "@types/pg": "^8.16.0", "@types/node": "25.3.0", "@types/react": "19.2.14", "@types/react-dom": "19.2.3", diff --git a/src/components/pages/portfolio/components/PortfolioHistoryBreakdownModal.tsx b/src/components/pages/portfolio/components/PortfolioHistoryBreakdownModal.tsx new file mode 100644 index 000000000..0984488c8 --- /dev/null +++ b/src/components/pages/portfolio/components/PortfolioHistoryBreakdownModal.tsx @@ -0,0 +1,377 @@ +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 { isYvUsdAddress, YVUSD_CHAIN_ID, YVUSD_UNLOCKED_ADDRESS } from '@pages/vaults/utils/yvUsd' +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 } from 'react' +import { Link } from 'react-router' +import { usePortfolioBreakdown } from '../hooks/usePortfolioBreakdown' +import type { 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 getStatusPriority(status: TPortfolioBreakdownVault['status']): number { + switch (status) { + case 'missing_metadata': + return 3 + case 'missing_pps': + return 2 + case 'missing_price': + return 1 + default: + return 0 + } +} + +function getMergedStatus(statuses: TPortfolioBreakdownVault['status'][]): TPortfolioBreakdownVault['status'] { + return statuses.reduce((selectedStatus, currentStatus) => { + return getStatusPriority(currentStatus) > getStatusPriority(selectedStatus) ? currentStatus : selectedStatus + }, 'ok') +} + +function getBreakdownDisplayKey(vault: TPortfolioBreakdownVault): string { + if (vault.chainId === YVUSD_CHAIN_ID && isYvUsdAddress(vault.vaultAddress)) { + return `${YVUSD_CHAIN_ID}:${YVUSD_UNLOCKED_ADDRESS}` + } + + return `${vault.chainId}:${toAddress(vault.vaultAddress)}` +} + +function getBreakdownDisplayVaults(vaults: TPortfolioBreakdownVault[]): TBreakdownDisplayVault[] { + const groupedVaults = new Map() + + vaults.forEach((vault) => { + const displayKey = getBreakdownDisplayKey(vault) + const normalizedVaultAddress = + vault.chainId === YVUSD_CHAIN_ID && isYvUsdAddress(vault.vaultAddress) + ? YVUSD_UNLOCKED_ADDRESS + : toAddress(vault.vaultAddress) + + const existingVault = groupedVaults.get(displayKey) + if (!existingVault) { + groupedVaults.set(displayKey, { + chainId: vault.chainId, + vaultAddress: normalizedVaultAddress, + usdValue: vault.usdValue ?? 0, + status: vault.status, + metadata: vault.metadata + }) + return + } + + groupedVaults.set(displayKey, { + ...existingVault, + usdValue: existingVault.usdValue + (vault.usdValue ?? 0), + status: getMergedStatus([existingVault.status, vault.status]), + metadata: existingVault.metadata ?? vault.metadata + }) + }) + + return [...groupedVaults.values()] +} + +export function PortfolioHistoryBreakdownModal({ + date, + isOpen, + onClose +}: TPortfolioHistoryBreakdownModalProps): ReactElement { + const { allVaults } = useYearn() + const { data, isLoading, error } = usePortfolioBreakdown(date, isOpen) + + 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(data?.vaults ?? []) + const yvUsdDisplayVault = allVaults[YVUSD_UNLOCKED_ADDRESS] as TKongVaultInput | undefined + + return displayedVaults + .map((vault): TEnrichedBreakdownVault => { + const normalizedVaultAddress = toAddress(vault.vaultAddress) + const isMergedYvUsdVault = vault.chainId === YVUSD_CHAIN_ID && normalizedVaultAddress === YVUSD_UNLOCKED_ADDRESS + const currentVault = isMergedYvUsdVault + ? yvUsdDisplayVault + : (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) + : isMergedYvUsdVault + ? `${import.meta.env.BASE_URL || '/'}yvUSD-seal.png` + : 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, data?.vaults]) + + const title = `Vault breakdown on ${formatBreakdownDate(date)}` + const issueCount = enrichedVaults.filter((vault) => vault.status !== 'ok').length + + return ( + + + +
+ + +
+
+ + +
+
+ + {title} + + {data ? ( +

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

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

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

+
+ ) : data && 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)} +

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

+ {data?.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..7241cff8e --- /dev/null +++ b/src/components/pages/portfolio/components/PortfolioHistoryChart.tsx @@ -0,0 +1,1084 @@ +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 { Tooltip } from '@shared/components/Tooltip' +import { useWeb3 } from '@shared/contexts/useWeb3' +import { useYearn } from '@shared/contexts/useYearn' +import { IconSpinner } from '@shared/icons/IconSpinner' +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 { + 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' +type TPortfolioHistoryChartTab = 'balance' | 'growth' | 'annualized' | 'index' +type TGrowthDisplayMode = 'index' | 'usd' | 'eth' + +type TPortfolioHistoryChartProps = { + balanceData: TPortfolioHistoryChartData | null + protocolReturnData: TPortfolioProtocolReturnHistoryChartData | null + protocolReturnSummary: TPortfolioProtocolReturnHistorySummary | null + protocolReturnFamilySeries: TPortfolioProtocolReturnHistoryFamilySeries + denomination: TPortfolioHistoryDenomination + onDenominationChange: (denomination: TPortfolioHistoryDenomination) => void + timeframe: TPortfolioHistoryChartTimeframe + onTimeframeChange: (timeframe: TPortfolioHistoryChartTimeframe) => void + balanceIsLoading: boolean + balanceIsEmpty?: boolean + balanceError?: Error | null + protocolReturnIsLoading: boolean + protocolReturnIsEmpty?: boolean + protocolReturnError?: Error | null + embedded?: boolean + className?: string +} + +type TChartPoint = { + date: string + value: number | null +} + +type TPortfolioHistoryTooltipProps = { + active?: boolean + payload?: Array<{ + value?: unknown + payload?: { + date?: string + value?: unknown + [key: string]: unknown + } + }> +} + +type TActiveChartState = { + activeLabel?: string | number +} + +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 GROWTH_DISPLAY_MODES: Array<{ id: TGrowthDisplayMode; label: string }> = [ + { id: 'index', label: 'Index' }, + { id: 'usd', label: 'USD' }, + { id: 'eth', label: 'ETH' } +] +const GROWTH_DISPLAY_TOOLTIP_COPY: Record = { + index: { + title: 'Index', + body: 'Normalized protocol-return index that starts at 100 for the selected timeframe. Best for mixed-asset wallets.' + }, + usd: { + title: 'USD', + body: 'Protocol growth during the selected timeframe in receipt-time USD equivalent.' + }, + eth: { + title: 'ETH', + body: 'Protocol growth during the selected timeframe in receipt-time ETH equivalent.' + } +} +const VAULT_GROWTH_MODES: Array<{ id: TPortfolioVaultGrowthChartMode; label: string }> = [ + { id: 'position', label: 'Position' }, + { id: 'index', label: 'Index' } +] +const VAULT_GROWTH_MODE_TOOLTIP_COPY: Record = { + position: { + title: 'Position', + body: 'Shows actual protocol gain from your deposited positions during the selected timeframe.' + }, + index: { + title: 'Index', + body: 'Shows vault performance normalized to 100, ignoring position size.' + } +} + +const INDEX_SERIES_COLORS = ['#2578ff', '#46a2ff', '#94adf2', '#7bb3a8', '#e1a23b', '#b67ae5'] as const + +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 renderGrowthDisplayTooltip(mode: TGrowthDisplayMode): ReactElement { + const copy = GROWTH_DISPLAY_TOOLTIP_COPY[mode] + + return ( +
+

{copy.title}

+

{copy.body}

+
+ ) +} + +function renderVaultGrowthModeTooltip(mode: TPortfolioVaultGrowthChartMode): ReactElement { + const copy = VAULT_GROWTH_MODE_TOOLTIP_COPY[mode] + + return ( +
+

{copy.title}

+

{copy.body}

+
+ ) +} + +function renderChartTitleTooltip( + activeTab: TPortfolioHistoryChartTab, + growthDisplayMode: TGrowthDisplayMode +): ReactElement { + return ( +
+ {getChartDescription(activeTab, growthDisplayMode)} +
+ ) +} + +function resolveGrowthDisplayMode( + selectedMode: TGrowthDisplayMode, + protocolReturnData: TPortfolioProtocolReturnHistoryChartData | null +): TGrowthDisplayMode { + const hasEthSeries = Boolean(protocolReturnData?.some((point) => point.growthWeightEth !== null)) + + return selectedMode === 'eth' && !hasEthSeries ? 'index' : selectedMode +} + +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 ? 'Click to see breakdown' : 'No breakdown available for this point'} + + ) : null} +
+ ) +} + +function getChartDescription(activeTab: TPortfolioHistoryChartTab, growthDisplayMode: TGrowthDisplayMode): string { + if (activeTab === 'growth') { + return growthDisplayMode === 'index' + ? 'Normalized protocol-return index for the whole wallet. Starts at 100 at the beginning of the selected timeframe.' + : growthDisplayMode === 'eth' + ? 'Protocol growth earned during the selected timeframe, shown in receipt-time ETH equivalent.' + : 'Protocol growth earned during the selected timeframe, shown in receipt-time USD equivalent.' + } + + if (activeTab === 'index') { + return 'Compare actual vault contribution with normalized vault performance.' + } + + if (activeTab === 'annualized') { + return 'Annualized protocol return based on time-weighted baseline exposure up to each point.' + } + + return 'Daily settled portfolio value across your current Yearn activity.' +} + +function getChartTitle(activeTab: TPortfolioHistoryChartTab, growthDisplayMode: TGrowthDisplayMode): string { + if (activeTab === 'growth') { + return growthDisplayMode === 'index' ? 'Growth Index' : 'Protocol Growth' + } + + if (activeTab === 'annualized') { + return 'Annualized Return' + } + + if (activeTab === 'index') { + return 'Growth Index' + } + + return 'Holdings History' +} + +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, + onDenominationChange, + timeframe, + onTimeframeChange, + balanceIsLoading, + balanceIsEmpty = false, + balanceError, + protocolReturnIsLoading, + protocolReturnIsEmpty = false, + protocolReturnError, + embedded = false, + className +}: TPortfolioHistoryChartProps): ReactElement { + const { address } = useWeb3() + const { allVaults } = useYearn() + const [activeTab, setActiveTab] = useState('balance') + const [growthDisplayModeOverride, setGrowthDisplayModeOverride] = useState(null) + const [vaultGrowthMode, setVaultGrowthMode] = useState('position') + 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(() => { + setGrowthDisplayModeOverride(null) + }, [address]) + + const sectionClassName = embedded + ? 'flex h-full flex-col gap-3 bg-surface px-5 py-4 md:px-6 md:pb-0 md:pt-5' + : '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 })) + }, [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 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) => { + 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(--color-neutral-400)' + } + } + }, [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 + } + + return filteredBalanceData.find((point) => point.date === date) ?? null + } + + 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) + } + + const renderHeader = (showControls = false): ReactElement => ( +
+
+
+ +

+ {getChartTitle(activeTab, resolvedGrowthDisplayMode)} +

+
+ {activeTab === 'balance' ? ( +
+ {(['usd', 'eth'] as const).map((nextDenomination) => ( + + ))} +
+ ) : activeTab === 'growth' ? ( +
+ {GROWTH_DISPLAY_MODES.map((mode) => ( + + + + ))} +
+ ) : activeTab === 'index' ? ( +
+ {VAULT_GROWTH_MODES.map((mode) => ( + + + + ))} +
+ ) : null} +
+
+ {showControls ? ( +
+
+ {CHART_TABS.map((tab) => ( + + ))} +
+
+ {(['30d', '90d', '1y', 'all'] as const).map((tf) => ( + + ))} +
+
+ ) : null} +
+ ) + + if (activeIsLoading) { + return ( +
+ {renderHeader(true)} +
+ +
+
+ ) + } + + if (activeError) { + return ( +
+ {renderHeader(true)} +
+

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

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

+ {'This is what your portfolio history can look like.'} +

+

+ {'Your Yearn deposits, yield, and vault rotations will start drawing a story here.'} +

+

+ { + 'Once you hold a Yearn vault, this chart will turn into a live timeline of your daily portfolio value.' + } +

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

{getEmptyMessage(activeTab, resolvedGrowthDisplayMode)}

+
+
+ ) + } + + if (activeTab === 'index') { + const vaultGrowthSeries = buildPortfolioVaultGrowthSeries(protocolReturnFamilySeries, familyLabelByVaultKey) + + return ( +
+ {renderHeader(true)} + + setIsBreakdownModalOpen(false)} + /> +
+ ) + } + + return ( +
+ {renderHeader(true)} +
+ 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..0d3d09295 --- /dev/null +++ b/src/components/pages/portfolio/components/PortfolioVaultGrowthChart.tsx @@ -0,0 +1,695 @@ +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' + +export type TPortfolioVaultGrowthChartMode = 'position' | 'index' +export type TPortfolioVaultGrowthChartTimeframe = '30d' | '90d' | '1y' | 'all' + +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 + 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 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): string { + 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): string { + 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 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/usePortfolioActivity.ts b/src/components/pages/portfolio/hooks/usePortfolioActivity.ts new file mode 100644 index 000000000..d1cc6b72d --- /dev/null +++ b/src/components/pages/portfolio/hooks/usePortfolioActivity.ts @@ -0,0 +1,42 @@ +import { useWeb3 } from '@shared/contexts/useWeb3' +import { fetchWithSchema } from '@shared/hooks/useFetch' +import { useInfiniteQuery } from '@tanstack/react-query' +import { + portfolioActivityResponseSchema, + type TPortfolioActivityEntry, + type TPortfolioActivityResponse +} from '../types/api' + +export function usePortfolioActivity(limit = 10, enabled = true) { + const { address } = useWeb3() + const isEnabled = Boolean(address) && enabled + + const query = useInfiniteQuery({ + queryKey: ['portfolio-activity', address, limit], + enabled: isEnabled, + initialPageParam: 0, + queryFn: ({ pageParam }) => + fetchWithSchema( + `/api/holdings/activity?address=${address}&limit=${limit}&offset=${Number(pageParam) || 0}`, + portfolioActivityResponseSchema, + { timeout: 30 * 1000 } + ), + getNextPageParam: (lastPage) => lastPage.pageInfo.nextOffset ?? undefined, + staleTime: 60 * 1000, + refetchOnWindowFocus: false + }) + + const entries: TPortfolioActivityEntry[] = query.data?.pages.flatMap((page) => page.entries) ?? [] + const isInitialLoading = query.isLoading || (query.isFetching && entries.length === 0) + const isEmpty = !isInitialLoading && !query.error && Boolean(address) && entries.length === 0 + + return { + data: entries, + isLoading: isInitialLoading, + isLoadingMore: query.isFetchingNextPage, + error: query.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..cece10340 --- /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}&debug=1&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/usePortfolioHistory.ts b/src/components/pages/portfolio/hooks/usePortfolioHistory.ts new file mode 100644 index 000000000..90fde4b4e --- /dev/null +++ b/src/components/pages/portfolio/hooks/usePortfolioHistory.ts @@ -0,0 +1,66 @@ +import { useWeb3 } from '@shared/contexts/useWeb3' +import { useFetch } from '@shared/hooks/useFetch' +import { useMemo } from 'react' +import type { + TPortfolioHistoryChartData, + TPortfolioHistoryDenomination, + TPortfolioHistorySimpleResponse, + TPortfolioHistoryTimeframe +} from '../types/api' +import { portfolioHistorySimpleResponseSchema } from '../types/api' + +export function usePortfolioHistory( + denomination: TPortfolioHistoryDenomination = 'usd', + timeframe: TPortfolioHistoryTimeframe = '1y', + enabled = true +) { + const { address } = useWeb3() + + const endpoint = useMemo(() => { + if (!address || !enabled) { + return null + } + return `/api/holdings/history?address=${address}&denomination=${denomination}&timeframe=${timeframe}&debug=1&fetchType=parallel` + }, [address, denomination, enabled, timeframe]) + + const { + data: rawData, + isLoading, + isFetching, + error + } = useFetch({ + endpoint, + schema: portfolioHistorySimpleResponseSchema, + config: { + cacheDuration: 5 * 60 * 1000, + keepPreviousData: false, + timeout: 2 * 60 * 1000 // 2 minutes for large holdings requests + } + }) + + const data = useMemo(() => { + if (!rawData?.dataPoints) { + return null + } + return rawData.dataPoints.map((point) => ({ + date: point.date, + value: point.value + })) + }, [rawData]) + + const isLoadingState = 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 + + return { + data, + denomination, + timeframe, + isLoading: isLoadingState, + error: visibleError, + isEmpty + } +} diff --git a/src/components/pages/portfolio/hooks/usePortfolioPnL.ts b/src/components/pages/portfolio/hooks/usePortfolioPnL.ts new file mode 100644 index 000000000..c03b4421e --- /dev/null +++ b/src/components/pages/portfolio/hooks/usePortfolioPnL.ts @@ -0,0 +1,32 @@ +import { useWeb3 } from '@shared/contexts/useWeb3' +import { useFetch } from '@shared/hooks/useFetch' +import { useMemo } from 'react' +import { portfolioPnlResponseSchema, type TPortfolioPnlSummary } from '../types/api' + +export function usePortfolioPnL() { + const { address } = useWeb3() + + const endpoint = useMemo(() => { + if (!address) { + return null + } + + return `/api/holdings/pnl?address=${address}&debug=1&fetchType=parallel` + }, [address]) + + const { data, isLoading, isFetching, error } = useFetch({ + endpoint, + schema: portfolioPnlResponseSchema, + config: { + cacheDuration: 5 * 60 * 1000, + keepPreviousData: false, + timeout: 2 * 60 * 1000 // 2 minutes for large holdings requests + } + }) + + return { + data: (data?.summary ?? null) as TPortfolioPnlSummary | null, + isLoading: isLoading || isFetching, + error + } +} diff --git a/src/components/pages/portfolio/hooks/usePortfolioProtocolReturn.ts b/src/components/pages/portfolio/hooks/usePortfolioProtocolReturn.ts new file mode 100644 index 000000000..1a0b69050 --- /dev/null +++ b/src/components/pages/portfolio/hooks/usePortfolioProtocolReturn.ts @@ -0,0 +1,65 @@ +import { useWeb3 } from '@shared/contexts/useWeb3' +import { useFetch } from '@shared/hooks/useFetch' +import { useEffect, useMemo } from 'react' +import { + portfolioProtocolReturnResponseSchema, + type TPortfolioProtocolReturnResponse, + type TPortfolioProtocolReturnSummary, + type TPortfolioProtocolReturnVault +} from '../types/api' + +export function usePortfolioProtocolReturn(enabled = true) { + const { address } = useWeb3() + + const endpoint = useMemo(() => { + if (!address || !enabled) { + return null + } + + return `/api/holdings/pnl/simple?address=${address}&debug=1&fetchType=parallel` + }, [address, enabled]) + + const { data, isLoading, isFetching, error } = useFetch({ + endpoint, + schema: portfolioProtocolReturnResponseSchema, + config: { + cacheDuration: 5 * 60 * 1000, + keepPreviousData: false, + timeout: 2 * 60 * 1000 + } + }) + + useEffect(() => { + if (!data || data.summary.isComplete) { + return + } + + const partialVaults = data.vaults + .filter((vault) => vault.status !== 'ok') + .map((vault: TPortfolioProtocolReturnVault) => ({ + chainId: vault.chainId, + vaultAddress: vault.vaultAddress, + symbol: vault.metadata.symbol, + tokenAddress: vault.metadata.tokenAddress, + status: vault.status, + issues: vault.issues + })) + + if (partialVaults.length === 0) { + return + } + + console.groupCollapsed( + `[Portfolio] Protocol return incomplete for ${partialVaults.length} vault${partialVaults.length === 1 ? '' : 's'}` + ) + console.table(partialVaults) + console.groupEnd() + }, [data]) + + return { + data: (data?.summary ?? null) as TPortfolioProtocolReturnSummary | null, + vaults: data?.vaults ?? [], + isLoading: isLoading || isFetching, + error + } +} diff --git a/src/components/pages/portfolio/hooks/usePortfolioProtocolReturnHistory.ts b/src/components/pages/portfolio/hooks/usePortfolioProtocolReturnHistory.ts new file mode 100644 index 000000000..8f8735a55 --- /dev/null +++ b/src/components/pages/portfolio/hooks/usePortfolioProtocolReturnHistory.ts @@ -0,0 +1,69 @@ +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' + +export function usePortfolioProtocolReturnHistory(timeframe: TPortfolioHistoryTimeframe = '1y', enabled = true) { + const { address } = useWeb3() + + const endpoint = useMemo(() => { + if (!address || !enabled) { + return null + } + + return `/api/holdings/pnl/simple-history?address=${address}&timeframe=${timeframe}&debug=1&fetchType=parallel` + }, [address, enabled, timeframe]) + + const { data, isLoading, isFetching, error } = useFetch({ + endpoint, + schema: portfolioProtocolReturnHistoryResponseSchema, + config: { + cacheDuration: 5 * 60 * 1000, + 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 isLoadingState = 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 + + return { + data: history, + summary, + familySeries, + timeframe, + isLoading: isLoadingState, + error: visibleError, + isEmpty + } +} diff --git a/src/components/pages/portfolio/index.tsx b/src/components/pages/portfolio/index.tsx index bf5a797af..bd6734590 100644 --- a/src/components/pages/portfolio/index.tsx +++ b/src/components/pages/portfolio/index.tsx @@ -13,15 +13,18 @@ import { TransactionOverlay, type TransactionStep } from '@pages/vaults/componen import { getVaultAddress, getVaultChainID, + getVaultName, getVaultStaking, + getVaultSymbol, 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 { 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 { SwitchChainPrompt } from '@shared/components/SwitchChainPrompt' +import { TokenLogo } from '@shared/components/TokenLogo' import { Tooltip } from '@shared/components/Tooltip' import { useNotifications } from '@shared/contexts/useNotifications' import { useWeb3 } from '@shared/contexts/useWeb3' @@ -30,12 +33,25 @@ import { useChainId, useSwitchChain } from '@shared/hooks/useAppWagmi' import { getVaultKey } from '@shared/hooks/useVaultFilterUtils' import { IconSpinner } from '@shared/icons/IconSpinner' 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 { PLAUSIBLE_EVENTS } from '@shared/utils/plausible' import type { CSSProperties, ReactElement } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useSearchParams } from 'react-router' +import Link from '/src/components/Link' +import type { TPortfolioHistoryChartTimeframe } from './components/PortfolioHistoryChart' +import { PortfolioHistoryChart } from './components/PortfolioHistoryChart' +import { usePortfolioActivity } from './hooks/usePortfolioActivity' +import { usePortfolioHistory } from './hooks/usePortfolioHistory' +import { usePortfolioProtocolReturn } from './hooks/usePortfolioProtocolReturn' +import { usePortfolioProtocolReturnHistory } from './hooks/usePortfolioProtocolReturnHistory' +import type { + TPortfolioActivityEntry, + TPortfolioHistoryDenomination, + TPortfolioHistoryTimeframe, + TPortfolioProtocolReturnSummary +} from './types/api' const currencyFormatter = new Intl.NumberFormat('en-US', { style: 'currency', @@ -46,6 +62,8 @@ 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: 'activity', label: 'Activity' }, @@ -56,13 +74,11 @@ type TPortfolioTabKey = (typeof PORTFOLIO_TABS)[number]['key'] type TPortfolioHeaderProps = Pick< TPortfolioModel, - | 'blendedMetrics' - | 'hasKatanaHoldings' - | 'isActive' - | 'isHoldingsLoading' - | 'isSearchingBalances' - | 'totalPortfolioValue' -> + 'blendedMetrics' | 'hasKatanaHoldings' | 'isHoldingsLoading' | 'isSearchingBalances' | 'totalPortfolioValue' +> & { + isProtocolReturnLoading: boolean + protocolReturnSummary: TPortfolioProtocolReturnSummary | null +} type TPortfolioHoldingsProps = Pick< TPortfolioModel, @@ -84,6 +100,157 @@ type TPortfolioActivityProps = Pick +const ACTIVITY_ACTION_LABELS: Record = { + deposit: 'Deposit', + withdraw: 'Withdraw', + stake: 'Stake', + unstake: 'Unstake' +} + +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 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 getActivityAddressExplorerUrl(chainId: number, address: string): string | null { + const network = SUPPORTED_NETWORKS.find((item) => item.id === chainId) + const explorerBaseUrl = network?.blockExplorers?.default?.url + + return explorerBaseUrl ? `${explorerBaseUrl}/address/${address}` : null +} + +function getActivityChainName(chainId: number): string { + return SUPPORTED_NETWORKS.find((item) => item.id === chainId)?.name ?? `Chain ${chainId}` +} + +function formatIndexedActivityDate(timestamp: number): string { + return new Date(timestamp * 1000).toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: 'numeric' + }) +} + +function IndexedActivityRow({ + entry, + displayName, + shareSymbol +}: { + entry: TPortfolioActivityEntry + displayName: string + shareSymbol: string | null +}): ReactElement { + const explorerUrl = getActivityExplorerUrl(entry.chainId, entry.txHash) + const vaultExplorerUrl = getActivityAddressExplorerUrl(entry.chainId, entry.familyVaultAddress) + const activityTitle = ACTIVITY_ACTION_LABELS[entry.action] + const activityLogoAddress = entry.familyVaultAddress || entry.vaultAddress + const isExitAction = entry.action === 'withdraw' || entry.action === 'unstake' + + return ( +
+
+
+

{activityTitle}

+
+ {'Success'} +
+
+ +
+
+ +
+
+
+ {isExitAction ? ( + <> +

{'Redeem:'}

+

+ {formatActivityDisplayAmount(entry.shareAmountFormatted, shareSymbol)} +

+

{'Receive:'}

+

+ {formatActivityDisplayAmount(entry.assetAmountFormatted, entry.assetSymbol)} +

+ + ) : ( + <> +

{'Token:'}

+

+ {formatActivityDisplayAmount(entry.assetAmountFormatted, entry.assetSymbol)} +

+

{'Vault:'}

+

+ {vaultExplorerUrl ? ( + + {displayName} + + ) : ( + displayName + )} +

+

{'Receive:'}

+

+ {formatActivityDisplayAmount(entry.shareAmountFormatted, shareSymbol)} +

+ + )} +

{'Chain:'}

+

{getActivityChainName(entry.chainId)}

+
+
+
+ +
+
+ {formatIndexedActivityDate(entry.timestamp)} +
+ {explorerUrl ? ( + + + + ) : ( + {'Explorer unavailable'} + )} +
+
+
+ ) +} + function PortfolioPageLayout({ children }: { children: ReactElement }): ReactElement { return (
@@ -95,11 +262,20 @@ function PortfolioPageLayout({ children }: { children: ReactElement }): ReactEle function PortfolioHeaderSection({ blendedMetrics, hasKatanaHoldings, - isActive, isHoldingsLoading, isSearchingBalances, + isProtocolReturnLoading, + protocolReturnSummary, totalPortfolioValue }: TPortfolioHeaderProps): ReactElement { + const annualizedProtocolReturnTooltip = ( +
+

{'All-time annualized protocol return while funds were actually held in your wallet.'}

+ +

{'Time-weighted by baseline vault exposure. Price moves are excluded.'}

+
+ ) + const katanaTooltipContent = (

{'*One or more vaults are receiving extra incentives.'}

@@ -144,6 +320,16 @@ function PortfolioHeaderSection({ return {currencyFormatter.format(value)} } + function renderSignedPercentMetric(value: number | null | undefined): ReactElement { + if (isProtocolReturnLoading) return metricSpinner + if (value === null || value === undefined) return {'—'} + + const absoluteValue = formatPercent(Math.abs(value), 2, 2, 10_000) + const signedValue = value > 0 ? `+${absoluteValue}` : value < 0 ? `-${absoluteValue}` : absoluteValue + + return {signedValue} + } + const metrics: TMetricBlock[] = [ { key: 'total-balance', @@ -173,26 +359,36 @@ function PortfolioHeaderSection({ key: '30-day-apy', header: , value: {renderApyMetric(blendedMetrics.blendedHistoricalAPY)} + }, + { + key: 'annualized-protocol-return', + header: ( + + ), + value: ( + + {renderSignedPercentMetric(protocolReturnSummary?.annualizedProtocolReturnPct)} + + ) } ] return ( -
-
- {'Monitor your balances, returns, and discover new vaults.'}
- } - > -

{'Account Overview'}

- +
+
+ {metrics.map((item) => ( +
+
{item.header}
+
{item.value}
+ {item.secondaryLabel ?
{item.secondaryLabel}
: null} + {item.footnote ?
{item.footnote}
: null} +
+ ))}
- {isActive && ( - - )}
) } @@ -238,11 +434,93 @@ function PortfolioTabSelector({ } function PortfolioActivitySection({ isActive, openLoginModal }: TPortfolioActivityProps): ReactElement { - const { cachedEntries, isLoading, error } = useNotifications() - const hasEntries = cachedEntries.length > 0 + const { allVaults } = useYearn() + const { cachedEntries, isLoading: notificationsLoading, error: notificationsError } = useNotifications() + const { + data: indexedEntries, + isLoading: indexedLoading, + isLoadingMore: indexedLoadingMore, + error: indexedError, + isEmpty: indexedEmpty, + hasMore: indexedHasMore, + loadMore: loadMoreIndexedActivity + } = usePortfolioActivity(10, isActive) + const unresolvedLocalEntries = useMemo( + () => + cachedEntries + .filter((entry) => entry.status !== 'success') + .toSorted((a, b) => (b.timeFinished ?? 0) - (a.timeFinished ?? 0)), + [cachedEntries] + ) + const hasUnresolvedLocalEntries = unresolvedLocalEntries.length > 0 + const hasIndexedEntries = indexedEntries.length > 0 + + function renderIndexedActivity(): ReactElement { + if (indexedLoading) { + return ( +
+ + {'Loading activity...'} +
+ ) + } + + if (indexedError) { + return ( +
+

{'Error loading activity'}

+

{indexedError.message}

+
+ ) + } + + if (indexedEmpty) { + return
{'No indexed activity to show.'}
+ } + + return ( +
+ {indexedEntries.map((entry) => { + const familyVault = allVaults[toAddress(entry.familyVaultAddress)] + const activityVault = allVaults[toAddress(entry.vaultAddress)] + const familyVaultSymbol = familyVault + ? getVaultSymbol(familyVault) + : activityVault + ? getVaultSymbol(activityVault) + : entry.assetSymbol + const displayName = familyVault + ? getVaultName(familyVault) + : activityVault + ? getVaultName(activityVault) + : truncateHex(entry.familyVaultAddress, 5) + const shareSymbol = + entry.action === 'stake' || entry.action === 'unstake' + ? familyVaultSymbol + ? `st-${familyVaultSymbol}` + : entry.assetSymbol + ? `st-${entry.assetSymbol}` + : null + : familyVault + ? getVaultSymbol(familyVault) + : activityVault + ? getVaultSymbol(activityVault) + : entry.assetSymbol + + return ( + + ) + })} +
+ ) + } function renderActivityContent(): ReactElement { - if (isLoading) { + if (notificationsLoading && indexedLoading && !hasUnresolvedLocalEntries) { return (
@@ -250,22 +528,56 @@ function PortfolioActivitySection({ isActive, openLoginModal }: TPortfolioActivi
) } - if (error) { + + if (notificationsError && !hasUnresolvedLocalEntries && !hasIndexedEntries && indexedEmpty) { return (

{'Error loading activity'}

-

{error}

+

{notificationsError}

) } - if (!hasEntries) { + + if (!hasUnresolvedLocalEntries && !hasIndexedEntries && indexedEmpty) { return
{'No transactions to show.'}
} + return ( -
- {cachedEntries.toReversed().map((entry) => ( - - ))} +
+ {hasUnresolvedLocalEntries && ( +
+
+

{'Pending local transactions'}

+

+ {'These entries come from this browser and may appear before the indexer catches up.'} +

+
+
+ {unresolvedLocalEntries.map((entry) => ( + + ))} +
+
+ )} + +
+
{renderIndexedActivity()}
+ {indexedHasMore && ( +
+ +
+ )} +
) } @@ -279,7 +591,7 @@ function PortfolioActivitySection({ isActive, openLoginModal }: TPortfolioActivi {!isActive ? ( @@ -833,6 +1145,7 @@ function PortfolioHoldingsSection({ showProductTypeChipOverride={true} showHoldingsChipOverride={false} mobileSecondaryMetric="holdings" + expandedChartVariant="portfolio-user-tvl-overlay" /> ))}
@@ -954,10 +1267,12 @@ function PortfolioSuggestedSection({ suggestedRows }: TPortfolioSuggestedProps): function PortfolioPage(): ReactElement { const model = usePortfolioModel() + const [historyDenomination, setHistoryDenomination] = useState('usd') + const [historyTimeframe, setHistoryTimeframe] = useState('1y') const [searchParams, setSearchParams] = useSearchParams() const varsRef = useRef(null) const breadcrumbsRef = useRef(null) - + const historyFetchTimeframe: TPortfolioHistoryTimeframe = historyTimeframe === 'all' ? 'all' : '1y' const activeTab = useMemo((): TPortfolioTabKey => { const tabParam = searchParams.get('tab') if (tabParam === 'activity' || tabParam === 'claim-rewards' || tabParam === 'positions') { @@ -965,6 +1280,24 @@ function PortfolioPage(): ReactElement { } return 'positions' }, [searchParams]) + const shouldLoadPositionsHistory = activeTab === 'positions' && model.isActive + const { + data: historyData, + denomination: resolvedHistoryDenomination, + isLoading: historyLoading, + error: historyError, + isEmpty: historyEmpty + } = usePortfolioHistory(historyDenomination, historyFetchTimeframe, shouldLoadPositionsHistory) + const { + data: protocolReturnHistoryData, + summary: protocolReturnHistorySummary, + familySeries: protocolReturnHistoryFamilySeries, + isLoading: protocolReturnHistoryLoading, + error: protocolReturnHistoryError, + isEmpty: protocolReturnHistoryEmpty + } = usePortfolioProtocolReturnHistory(historyFetchTimeframe, shouldLoadPositionsHistory) + const { data: protocolReturnSummary, isLoading: protocolReturnLoading } = + usePortfolioProtocolReturn(shouldLoadPositionsHistory) const handleTabSelect = useCallback( (tab: TPortfolioTabKey) => { @@ -1016,11 +1349,59 @@ function PortfolioPage(): ReactElement { } }, []) + const overviewHeading = ( + {'Monitor your balances, returns, and discover new vaults.'}
+ } + > +

{'Account Overview'}

+ + ) + function renderTabContent(): ReactElement | null { switch (activeTab) { case 'positions': return (
+ {model.isActive ? ( +
+
+ +
+ +
+
+
+ ) : null}
-
+
-
- - +
+
+
{overviewHeading}
+ +
-
+
{renderTabContent()}
diff --git a/src/components/pages/portfolio/types/api.ts b/src/components/pages/portfolio/types/api.ts new file mode 100644 index 000000000..48ac56639 --- /dev/null +++ b/src/components/pages/portfolio/types/api.ts @@ -0,0 +1,223 @@ +import { z } from 'zod' + +const portfolioHistorySimpleDataPointSchema = z.object({ + date: z.string(), + value: z.number() +}) + +const portfolioProtocolReturnHistoryDataPointSchema = z.object({ + date: z.string(), + growthWeightUsd: z.number(), + growthWeightEth: z.number().nullable(), + protocolReturnPct: z.number().nullable(), + annualizedProtocolReturnPct: z.number().nullable(), + growthIndex: z.number().nullable() +}) + +const portfolioProtocolReturnHistorySummarySchema = z.object({ + totalVaults: z.number(), + completeVaults: z.number(), + partialVaults: z.number(), + recommendedGrowthDisplay: z.enum(['usd', 'eth', 'index']), + recommendedGrowthDisplayReason: z.enum(['stable_dominant', 'eth_dominant', 'mixed']), + openBaselineCompositionUsd: z.object({ + stable: z.number(), + ethFamily: z.number(), + other: z.number() + }), + isComplete: z.boolean() +}) + +const portfolioProtocolReturnHistoryFamilyPointSchema = z.object({ + date: z.string(), + timestamp: z.number(), + protocolReturnPct: z.number().nullable(), + growthWeightUsd: z.number().nullable(), + growthIndex: z.number().nullable() +}) + +const portfolioProtocolReturnHistoryFamilySeriesSchema = z.object({ + chainId: z.number(), + vaultAddress: z.string(), + symbol: z.string().nullable(), + status: z.enum(['ok', 'missing_metadata', 'missing_pps', 'missing_receipt_price', 'partial']), + dataPoints: z.array(portfolioProtocolReturnHistoryFamilyPointSchema) +}) + +const portfolioBreakdownVaultSchema = z.object({ + chainId: z.number(), + vaultAddress: z.string(), + shares: z.string(), + sharesFormatted: z.number(), + pricePerShare: z.number().nullable(), + tokenPrice: z.number().nullable(), + usdValue: z.number().nullable(), + metadata: z + .object({ + symbol: z.string(), + decimals: z.number(), + tokenAddress: z.string() + }) + .nullable(), + status: z.enum(['ok', 'missing_metadata', 'missing_pps', 'missing_price']) +}) + +const portfolioPnlCategoryBreakdownSchema = z.object({ + totalPnlUsd: z.number(), + totalEconomicGainUsd: z.number() +}) + +export const portfolioHistorySimpleResponseSchema = z.object({ + address: z.string(), + denomination: z.enum(['usd', 'eth']).optional().default('usd'), + timeframe: z.enum(['1y', 'all']).optional().default('1y'), + dataPoints: z.array(portfolioHistorySimpleDataPointSchema) +}) + +export const portfolioProtocolReturnHistoryResponseSchema = z.object({ + address: z.string(), + timeframe: z.enum(['1y', 'all']).optional().default('1y'), + summary: portfolioProtocolReturnHistorySummarySchema, + dataPoints: z.array(portfolioProtocolReturnHistoryDataPointSchema), + familySeries: z.array(portfolioProtocolReturnHistoryFamilySeriesSchema).optional().default([]) +}) + +export const portfolioBreakdownResponseSchema = z.object({ + address: z.string(), + version: z.string(), + date: z.string(), + timestamp: z.number(), + summary: z.object({ + totalVaults: z.number(), + vaultsWithShares: z.number(), + totalUsdValue: z.number(), + missingMetadata: z.number(), + missingPps: z.number(), + missingPrice: z.number() + }), + vaults: z.array(portfolioBreakdownVaultSchema), + issues: z.object({ + missingMetadata: z.array(z.string()), + missingPps: z.array(z.string()), + missingPrice: z.array(z.string()) + }), + message: z.string().optional() +}) + +const portfolioPnlSummarySchema = z.object({ + totalVaults: z.number(), + partialVaults: z.number(), + totalWindfallPnlUsd: z.number(), + totalPnlUsd: z.number(), + totalEconomicGainUsd: z.number(), + byCategory: z.object({ + stable: portfolioPnlCategoryBreakdownSchema, + volatile: portfolioPnlCategoryBreakdownSchema + }), + isComplete: z.boolean() +}) + +const portfolioProtocolReturnSummarySchema = z.object({ + totalVaults: z.number(), + completeVaults: z.number(), + partialVaults: z.number(), + baselineWeightUsd: z.number(), + baselineExposureWeightUsdYears: z.number(), + growthWeightUsd: z.number(), + realizedGrowthWeightUsd: z.number(), + unrealizedGrowthWeightUsd: z.number(), + protocolReturnPct: z.number().nullable(), + annualizedProtocolReturnPct: z.number().nullable(), + isComplete: z.boolean() +}) + +const portfolioActivityEntrySchema = z.object({ + chainId: z.number(), + txHash: z.string(), + timestamp: z.number(), + action: z.enum(['deposit', 'withdraw', 'stake', 'unstake']), + vaultAddress: z.string(), + familyVaultAddress: z.string(), + assetSymbol: z.string().nullable(), + assetAmount: z.string(), + assetAmountFormatted: z.number().nullable(), + shareAmount: z.string(), + shareAmountFormatted: z.number().nullable(), + status: z.enum(['ok', 'missing_metadata']) +}) + +export const portfolioActivityResponseSchema = z.object({ + address: z.string(), + version: z.enum(['all', 'v2', 'v3']).optional().default('all'), + limit: z.number(), + offset: z.number(), + pageInfo: z.object({ + hasMore: z.boolean(), + nextOffset: z.number().nullable() + }), + entries: z.array(portfolioActivityEntrySchema) +}) + +const portfolioProtocolReturnVaultSchema = z.object({ + chainId: z.number(), + vaultAddress: z.string(), + status: z.enum(['ok', 'missing_metadata', 'missing_pps', 'missing_receipt_price', 'partial']), + issues: z.array(z.enum(['missing_metadata', 'missing_pps', 'missing_receipt_price', 'unmatched_exit'])), + metadata: z.object({ + symbol: z.string().nullable(), + decimals: z.number(), + assetDecimals: z.number(), + tokenAddress: z.string().nullable() + }) +}) + +export const portfolioPnlResponseSchema = z.object({ + address: z.string(), + summary: portfolioPnlSummarySchema +}) + +export const portfolioProtocolReturnResponseSchema = z.object({ + address: z.string(), + summary: portfolioProtocolReturnSummarySchema, + vaults: z.array(portfolioProtocolReturnVaultSchema) +}) + +export type TPortfolioHistorySimpleResponse = z.infer +export type TPortfolioProtocolReturnHistoryResponse = z.infer +export type TPortfolioProtocolReturnHistorySummary = z.infer +export type TPortfolioBreakdownResponse = z.infer +export type TPortfolioBreakdownVault = z.infer +export type TPortfolioPnlResponse = z.infer +export type TPortfolioPnlSummary = z.infer +export type TPortfolioProtocolReturnResponse = z.infer +export type TPortfolioProtocolReturnSummary = z.infer +export type TPortfolioProtocolReturnVault = z.infer +export type TPortfolioActivityResponse = z.infer +export type TPortfolioActivityEntry = z.infer +export type TPortfolioHistoryDenomination = z.infer['denomination'] +export type TPortfolioHistoryTimeframe = z.infer['timeframe'] +export type TPortfolioHistoryChartData = Array<{ + date: string + value: number +}> +export type TPortfolioProtocolReturnHistoryChartData = Array<{ + date: string + growthWeightUsd: number + growthWeightEth: number | null + protocolReturnPct: number | null + annualizedProtocolReturnPct: number | null + growthIndex: number | null +}> +export type TPortfolioProtocolReturnHistoryFamilySeries = Array<{ + chainId: number + vaultAddress: string + symbol: string | null + status: 'ok' | 'missing_metadata' | 'missing_pps' | 'missing_receipt_price' | 'partial' + dataPoints: Array<{ + date: string + timestamp: number + protocolReturnPct: number | null + growthWeightUsd: number | null + growthIndex: number | null + }> +}> diff --git a/src/components/pages/vaults/[chainID]/[address].tsx b/src/components/pages/vaults/[chainID]/[address].tsx index d3ba72824..1bb477950 100644 --- a/src/components/pages/vaults/[chainID]/[address].tsx +++ b/src/components/pages/vaults/[chainID]/[address].tsx @@ -318,7 +318,7 @@ function Index(): ReactElement | null { const location = useLocation() const navigate = useNavigate() const chainId = Number(params.chainID) - const { getBalance, onRefresh } = useWallet() + const { getBalance, onRefresh, isLoading: isWalletLoading } = useWallet() const { address } = useWeb3() const { vaults, allVaults, isLoadingVaultList, enableVaultListFetch } = useYearn() const { @@ -531,6 +531,7 @@ function Index(): ReactElement | null { const canShowMigrateAction = isMigratable && vaultShareBalance > 0n const isRetired = Boolean(currentVault?.info?.isRetired) const hasUserFundsInVault = vaultShareBalance > 0n || stakingShareBalance > 0n + const canShowUserCharts = !isWalletLoading && hasUserFundsInVault const retiredVaultAlertMessage = useMemo(() => { if (!isRetired || !currentVault) return null return getRetiredVaultAlertMessage({ vault: currentVault, hasUserFundsInVault }) @@ -768,10 +769,13 @@ function Index(): ReactElement | null { ) : ( ) }, diff --git a/src/components/pages/vaults/components/detail/VaultChartsSection.tsx b/src/components/pages/vaults/components/detail/VaultChartsSection.tsx index 040df49ae..90f0d73ec 100644 --- a/src/components/pages/vaults/components/detail/VaultChartsSection.tsx +++ b/src/components/pages/vaults/components/detail/VaultChartsSection.tsx @@ -1,9 +1,10 @@ import { KATANA_CHAIN_ID } from '@pages/vaults/constants/addresses' import { useVaultChartTimeseries } from '@pages/vaults/hooks/useVaultChartTimeseries' +import { useVaultUserHistory } from '@pages/vaults/hooks/useVaultUserHistory' import { transformVaultChartData } from '@pages/vaults/utils/charts' import { cl, SELECTOR_BAR_STYLES } from '@shared/utils' import type { ReactElement } from 'react' -import { lazy, Suspense, useMemo, useState } from 'react' +import { lazy, Suspense, useEffect, useMemo, useState } from 'react' import { ChartErrorBoundary } from './charts/ChartErrorBoundary' import ChartSkeleton from './charts/ChartSkeleton' import ChartsLoader from './charts/ChartsLoader' @@ -12,6 +13,9 @@ import { FixedHeightChartContainer } from './charts/FixedHeightChartContainer' const APYChart = lazy(() => import('./charts/APYChart').then((m) => ({ default: m.APYChart }))) const PPSChart = lazy(() => import('./charts/PPSChart').then((m) => ({ default: m.PPSChart }))) const TVLChart = lazy(() => import('./charts/TVLChart').then((m) => ({ default: m.TVLChart }))) +const VaultTvlGrowthChart = lazy(() => + import('./charts/VaultTvlGrowthChart').then((m) => ({ default: m.VaultTvlGrowthChart })) +) type VaultChartsSectionProps = { chainId: number @@ -23,6 +27,9 @@ type VaultChartsSectionProps = { shouldRenderSelectors?: boolean chartHeightPx?: number chartHeightMdPx?: number + enableUserCharts?: boolean + userUnitLabel?: string + overlayUserPositionOnTvlTab?: boolean } export const VAULT_CHART_TIMEFRAME_OPTIONS = [ @@ -34,14 +41,24 @@ export const VAULT_CHART_TIMEFRAME_OPTIONS = [ export type TVaultChartTimeframe = (typeof VAULT_CHART_TIMEFRAME_OPTIONS)[number]['value'] -export type TVaultChartTab = 'historical-pps' | 'historical-apy' | 'historical-tvl' +type TBaseVaultChartTab = 'historical-pps' | 'historical-apy' | 'historical-tvl' +type TUserVaultChartTab = 'user-position' +export type TVaultChartTab = TBaseVaultChartTab | TUserVaultChartTab -export const VAULT_CHART_TABS: Array<{ id: TVaultChartTab; label: string }> = [ +export const VAULT_CHART_TABS: Array<{ id: TBaseVaultChartTab; label: string }> = [ { id: 'historical-apy', label: '30-Day APY' }, { id: 'historical-pps', label: 'Performance' }, { id: 'historical-tvl', label: 'TVL' } ] +const USER_VAULT_CHART_TABS: Array<{ id: TUserVaultChartTab; label: string }> = [ + { id: 'user-position', label: 'Your Position' } +] + +function isUserChartTab(tab: TVaultChartTab): tab is TUserVaultChartTab { + return tab === 'user-position' +} + export function VaultChartsSection({ chainId, vaultAddress, @@ -51,7 +68,10 @@ export function VaultChartsSection({ onTimeframeChange, shouldRenderSelectors = true, chartHeightPx, - chartHeightMdPx + chartHeightMdPx, + enableUserCharts = false, + userUnitLabel = 'assets', + overlayUserPositionOnTvlTab = false }: VaultChartsSectionProps): ReactElement { const { data, error, isLoading } = useVaultChartTimeseries({ chainId, @@ -60,9 +80,6 @@ export function VaultChartsSection({ const transformed = useMemo(() => transformVaultChartData(data), [data]) - const chartsLoading = isLoading || !transformed.aprApyData || !transformed.ppsData || !transformed.tvlData - const hasError = Boolean(error) - const [uncontrolledTab, setUncontrolledTab] = useState('historical-apy') const [uncontrolledTimeframe, setUncontrolledTimeframe] = useState('1y') @@ -70,7 +87,48 @@ export function VaultChartsSection({ const activeTimeframe = timeframe ?? uncontrolledTimeframe const setActiveTab = onChartTabChange ?? setUncontrolledTab const setActiveTimeframe = onTimeframeChange ?? setUncontrolledTimeframe - const showApyDisclaimer = shouldRenderSelectors && activeTab === 'historical-apy' && chainId === KATANA_CHAIN_ID + const availableTabs = useMemo( + () => [...VAULT_CHART_TABS, ...(enableUserCharts ? USER_VAULT_CHART_TABS : [])], + [enableUserCharts] + ) + const fallbackTab = availableTabs[0]?.id ?? 'historical-apy' + const resolvedActiveTab = availableTabs.some((tab) => tab.id === activeTab) ? activeTab : fallbackTab + const activeTabIsUserChart = isUserChartTab(resolvedActiveTab) + const shouldOverlayUserPositionOnTvlTab = overlayUserPositionOnTvlTab && resolvedActiveTab === 'historical-tvl' + const showApyDisclaimer = + shouldRenderSelectors && resolvedActiveTab === 'historical-apy' && chainId === KATANA_CHAIN_ID + const { + balanceData: userBalanceData, + growthData: userGrowthData, + isLoading: isUserHistoryLoading, + isEmpty: isUserHistoryEmpty, + error: userHistoryError + } = useVaultUserHistory({ + chainId, + vaultAddress, + timeframe: activeTimeframe, + enabled: (enableUserCharts && activeTabIsUserChart) || shouldOverlayUserPositionOnTvlTab + }) + + useEffect(() => { + if (resolvedActiveTab === activeTab) { + return + } + + setActiveTab(resolvedActiveTab) + }, [activeTab, resolvedActiveTab, setActiveTab]) + + const vaultChartsLoading = isLoading || !transformed.aprApyData || !transformed.ppsData || !transformed.tvlData + const chartsLoading = shouldOverlayUserPositionOnTvlTab + ? isUserHistoryLoading + : activeTabIsUserChart + ? isUserHistoryLoading + : vaultChartsLoading + const hasError = Boolean( + shouldOverlayUserPositionOnTvlTab ? userHistoryError : activeTabIsUserChart ? userHistoryError : error + ) + const showUserEmptyState = + (activeTabIsUserChart || shouldOverlayUserPositionOnTvlTab) && !chartsLoading && !hasError && isUserHistoryEmpty return (
@@ -78,7 +136,7 @@ export function VaultChartsSection({
- {VAULT_CHART_TABS.map((tab) => ( + {availableTabs.map((tab) => ( + + {'Tenderly'} + + {canToggleMode && ( + + )} +
) } @@ -182,6 +265,8 @@ function AppHeader(): ReactElement { {!isHomePage && ( <>
+ + - - + {isOpen ?
{section.content}
: null} +
+ ) + } return (
- - {isOpen ?
{section.content}
: null} + {section.content}
) + })() + + if (!needsScaledYvUsdBanner) { + return sectionNode } - return ( -
- {section.content} -
- ) + return [ + sectionNode, + + ] })} {renderableSections.length > 0 ?
: null}
diff --git a/src/components/pages/vaults/components/detail/QuickStatsGrid.test.tsx b/src/components/pages/vaults/components/detail/QuickStatsGrid.test.tsx index 48e4920a1..3c4c7074a 100644 --- a/src/components/pages/vaults/components/detail/QuickStatsGrid.test.tsx +++ b/src/components/pages/vaults/components/detail/QuickStatsGrid.test.tsx @@ -1,7 +1,13 @@ import type { ReactNode } from 'react' import { renderToStaticMarkup } from 'react-dom/server' import { describe, expect, it, vi } from 'vitest' -import { MobileKeyMetrics, YvUsdApyStatBox } from './QuickStatsGrid' + +Object.defineProperty(globalThis, 'location', { + value: { + href: 'http://localhost/' + }, + configurable: true +}) vi.mock('@shared/contexts/useWeb3', () => ({ useWeb3: () => ({ @@ -10,6 +16,10 @@ vi.mock('@shared/contexts/useWeb3', () => ({ }) })) +vi.mock('@hooks/usePlausible', () => ({ + usePlausible: () => vi.fn() +})) + vi.mock('@pages/vaults/components/table/VaultForwardAPY', () => ({ VaultForwardAPY: () =>
{'Default APY'}
})) @@ -24,6 +34,8 @@ vi.mock('@pages/vaults/components/table/APYDetailsModal', () => ({ ) : null })) +const { MobileKeyMetrics, YvUsdApyStatBox } = await import('./QuickStatsGrid') + const TEST_VAULT = { version: '3.0.0', chainID: 1, diff --git a/src/components/pages/vaults/components/detail/QuickStatsGrid.tsx b/src/components/pages/vaults/components/detail/QuickStatsGrid.tsx index 216cc236e..54c691c0b 100644 --- a/src/components/pages/vaults/components/detail/QuickStatsGrid.tsx +++ b/src/components/pages/vaults/components/detail/QuickStatsGrid.tsx @@ -210,7 +210,8 @@ export function YvUsdApyStatBox({ activeVariant, onVariantChange, lockedHasInfinifiPoints = false, - unlockedHasInfinifiPoints = false + unlockedHasInfinifiPoints = false, + title = 'yvUSD APY' }: { lockedApy: number unlockedApy: number @@ -218,6 +219,7 @@ export function YvUsdApyStatBox({ onVariantChange?: (variant: TYvUsdVariant) => void lockedHasInfinifiPoints?: boolean unlockedHasInfinifiPoints?: boolean + title?: string }): ReactElement { const [internalVariant, setInternalVariant] = useState('locked') const [isModalOpen, setIsModalOpen] = useState(false) @@ -261,20 +263,12 @@ export function YvUsdApyStatBox({ } role={'button'} tabIndex={0} - aria-label={'Open yvUSD APY details'} + aria-label={`Open ${title} details`} onClick={() => setIsModalOpen(true)} onKeyDown={handleCardKeyDown} >

{'Est. APY'}

-
- +
{selectedLabel}
+
- setIsModalOpen(false)} title={'yvUSD APY'}> + setIsModalOpen(false)} title={title}> {`${label}:`} - {value} + {hasValue ? {value} : null} {icon ? {icon} : null} {suffix ? {suffix} : null} @@ -49,23 +56,36 @@ function InlineHeading({ label, value, icon, suffix }: TInlineHeading): ReactEle type TExpandableInfoItem = { label: string - value: ReactNode + value?: ReactNode children: ReactNode className?: string icon?: ReactNode + isOpenByDefault?: boolean } -function ExpandableInfoItem({ label, value, children, className, icon }: TExpandableInfoItem): ReactElement { - const [isOpen, setIsOpen] = useState(false) +function ExpandableInfoItem({ + label, + value, + children, + className, + icon, + isOpenByDefault +}: TExpandableInfoItem): ReactElement { + const [isOpen, setIsOpen] = useState(Boolean(isOpenByDefault)) return (
{ setIsOpen(event.currentTarget.open) }} > - + -
{children}
+
{children}
) } +function AddressLink({ + address, + explorerBase, + label +}: { + address: string + explorerBase: string + label: string +}): ReactElement { + const explorerHref = `${explorerBase}/address/${address}` + + return ( +
+

{label}

+
+ {explorerBase ? ( + + {truncateHex(address, 4)} + + + ) : ( + {truncateHex(address, 4)} + )} + +
+
+ ) +} + +function VaultAddressesInfo({ + chainID, + addresses +}: { + chainID: number + addresses: TVaultAddressItem[] +}): ReactElement | null { + const explorerBase = getNetwork(chainID).defaultBlockExplorer + + if (addresses.length === 0) { + return null + } + + return ( + +
+ {addresses.map((item) => ( + + ))} +
+
+ ) +} + export function VaultAboutSection({ currentVault, className, + vaultAddresses = [], + additionalFeaturesContent, showKindTag = true, showVaultAddress = false }: { currentVault: TKongVaultInput className?: string + vaultAddresses?: TVaultAddressItem[] showKindTag?: boolean showVaultAddress?: boolean + additionalFeaturesContent?: ReactNode showHiddenTag?: boolean isHidden?: boolean }): ReactElement { @@ -206,7 +298,15 @@ export function VaultAboutSection({ ) : null}
-
+
+ {additionalFeaturesContent ? ( + +
{additionalFeaturesContent}
+
+ ) : null} + + +

{chainDescription} @@ -241,11 +341,13 @@ export function VaultAboutSection({

- { - 'Management fees are claimed from earned yield, pro-rated and up to the stated percentage of principal.' - } + {'Management fees'} + {' are claimed from earned yield, pro-rated and up to the stated percentage of principal.'} +

+

+ {'Performance fees'} + {' are claimed from earned yield, up to the stated percentage of yield earned.'}

-

{'Performance fees are claimed from earned yield, up to the stated percentage of yield earned.'}

diff --git a/src/components/pages/vaults/components/detail/VaultDetailsHeader.test.tsx b/src/components/pages/vaults/components/detail/VaultDetailsHeader.test.tsx new file mode 100644 index 000000000..82a8abacc --- /dev/null +++ b/src/components/pages/vaults/components/detail/VaultDetailsHeader.test.tsx @@ -0,0 +1,354 @@ +import { YVBTC_LOCKED_ADDRESS, YVBTC_UNLOCKED_ADDRESS } from '@pages/vaults/utils/yvBtc' +import { YVUSD_LOCKED_ADDRESS, YVUSD_UNLOCKED_ADDRESS } from '@pages/vaults/utils/yvUsd' +import type { ReactNode } from 'react' +import { renderToStaticMarkup } from 'react-dom/server' +import { MemoryRouter } from 'react-router' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const useVaultUserDataMock = vi.hoisted(() => + vi.fn(() => ({ + depositedValue: 0n, + pricePerShare: 0n, + assetToken: { + decimals: 18, + symbol: 'yvUSD' + }, + vaultToken: { + symbol: 'yvUSD' + }, + stakingToken: undefined, + availableToDeposit: 0n, + depositedShares: 0n, + stakingWithdrawableAssets: 0n, + stakingRedeemableShares: 0n, + isLoading: false, + refetch: vi.fn() + })) +) + +Object.defineProperty(globalThis, 'location', { + value: { + href: 'http://localhost/' + }, + configurable: true +}) + +vi.mock('@shared/contexts/useWeb3', () => ({ + useWeb3: () => ({ + address: undefined, + isActive: false + }) +})) + +vi.mock('@shared/contexts/useYearn', () => ({ + useYearn: () => ({ + getPrice: () => ({ + normalized: 1 + }) + }) +})) + +vi.mock('@shared/hooks/useAppWagmi', () => ({ + useReadContract: () => ({ + data: undefined + }) +})) + +vi.mock('@shared/hooks/useChainTimestamp', () => ({ + useChainTimestamp: () => ({ + timestamp: 0 + }) +})) + +vi.mock('@pages/vaults/hooks/useVaultUserData', () => ({ + useVaultUserData: useVaultUserDataMock +})) + +vi.mock('@pages/vaults/hooks/useYvUsdVaults', () => ({ + useYvUsdVaults: () => ({ + metrics: undefined, + unlockedVault: { + address: YVUSD_UNLOCKED_ADDRESS, + token: { + address: '0x0000000000000000000000000000000000000002', + symbol: 'USDC', + decimals: 6 + }, + tvl: { + price: 1, + tvl: 100 + }, + apr: { + forwardAPR: { + netAPR: 0.05 + }, + points: { + monthAgo: 0.04, + weekAgo: 0.03 + } + } + }, + lockedVault: { + address: YVUSD_LOCKED_ADDRESS, + token: { + address: YVUSD_UNLOCKED_ADDRESS, + symbol: 'yvUSD', + decimals: 18 + }, + tvl: { + price: 1, + tvl: 50 + }, + apr: { + forwardAPR: { + netAPR: 0.06 + }, + points: { + monthAgo: 0.05, + weekAgo: 0.04 + } + } + } + }) +})) + +vi.mock('@pages/vaults/hooks/useYvBtcVaults', () => ({ + useYvBtcVaults: () => ({ + metrics: { + unlocked: { + apy: 0.04, + tvl: 100, + hasInfinifiPoints: false + }, + locked: { + apy: 0, + tvl: 0, + hasInfinifiPoints: false + } + }, + unlockedVault: { + address: YVBTC_UNLOCKED_ADDRESS, + token: { + address: '0x0000000000000000000000000000000000000003', + symbol: 'WBTC', + decimals: 8 + }, + tvl: { + price: 100000, + tvl: 100 + }, + apr: { + forwardAPR: { + netAPR: 0.04 + }, + points: { + monthAgo: 0.03, + weekAgo: 0.02 + } + } + }, + lockedVault: { + address: YVBTC_LOCKED_ADDRESS, + token: { + address: YVBTC_UNLOCKED_ADDRESS, + symbol: 'yvBTC', + decimals: 18 + }, + tvl: { + price: 0, + tvl: 0 + }, + apr: { + forwardAPR: { + netAPR: 0 + }, + points: { + monthAgo: 0, + weekAgo: 0 + } + } + } + }) +})) + +vi.mock('@shared/components/MetricsCard', () => ({ + METRIC_FOOTNOTE_CLASS: 'metric-footnote', + METRIC_VALUE_CLASS: 'metric-value', + MetricHeader: ({ label }: { label: string }) => {label}, + MetricsCard: ({ + items, + className + }: { + items: Array<{ key: string; header: ReactNode; value: ReactNode; footnote?: ReactNode }> + className?: string + }) => ( +
+ {items.map((item) => ( +
+ {item.header} + {item.value} + {item.footnote} +
+ ))} +
+ ) +})) + +vi.mock('@pages/vaults/components/table/VaultForwardAPY', () => ({ + VaultForwardAPY: () =>
{'Forward APY'}
+})) + +vi.mock('@pages/vaults/components/table/VaultHistoricalAPY', () => ({ + VaultHistoricalAPY: () =>
{'Historical APY'}
+})) + +vi.mock('@pages/vaults/components/table/VaultTVL', () => ({ + VaultTVL: () =>
{'TVL'}
+})) + +vi.mock('@pages/vaults/components/widget', () => ({ + WidgetTabs: () =>
{'Widget Tabs'}
+})) + +vi.mock('@pages/vaults/components/yvUSD/YvUsdBreakdown', () => ({ + YvUsdApyTooltipContent: () =>
{'yvUSD APY tooltip'}
, + YvUsdTvlTooltipContent: () =>
{'yvUSD TVL tooltip'}
+})) + +vi.mock('@pages/vaults/components/yvUSD/YvUsdHeaderBanner', () => ({ + YvUsdHeaderBanner: () =>
{'yvUSD banner'}
+})) + +const { VaultDetailsHeaderPresentation } = await import('./VaultDetailsHeader') + +const YVUSD_VAULT = { + version: '3.0.4', + address: YVUSD_UNLOCKED_ADDRESS, + chainID: 1, + type: 'Automated Yearn Vault', + kind: 'Multi Strategy', + symbol: 'yvUSD', + name: 'yvUSD', + description: '', + category: 'Stablecoin', + decimals: 18, + token: { + address: '0x0000000000000000000000000000000000000002', + symbol: 'USDC', + name: 'USD Coin', + decimals: 6 + }, + tvl: { + tvl: 150, + totalAssets: 150000000000000000000n, + price: 1 + }, + apr: { + netAPR: 0.05, + forwardAPR: { + netAPR: 0.05 + }, + points: { + monthAgo: 0.04, + weekAgo: 0.03 + } + }, + strategies: [], + staking: { + address: '0x0000000000000000000000000000000000000000', + available: false, + source: '', + rewards: [] + }, + migration: { + available: false, + address: '0x0000000000000000000000000000000000000000', + contract: '0x0000000000000000000000000000000000000000' + }, + info: { + riskLevel: 2, + riskScore: {}, + riskScoreComment: '', + sourceURL: '', + uiNotice: '', + isRetired: false, + isBoosted: false, + isHighlighted: false, + isHidden: false + } +} as const + +const YVBTC_VAULT = { + ...YVUSD_VAULT, + address: YVBTC_UNLOCKED_ADDRESS, + symbol: 'yvBTC', + name: 'yvBTC', + token: { + address: '0x0000000000000000000000000000000000000003', + symbol: 'WBTC', + name: 'Wrapped Bitcoin', + decimals: 8 + }, + tvl: { + tvl: 100, + totalAssets: 100000000n, + price: 100000 + } +} as const + +describe('VaultDetailsHeaderPresentation', () => { + beforeEach(() => { + useVaultUserDataMock.mockClear() + }) + + it('uses the standard compressed token logo size for yvUSD', () => { + const html = renderToStaticMarkup( + + + + ) + + expect(html).toContain('style="width:32px;height:32px"') + expect(html).not.toContain('style="width:40px;height:40px"') + expect(html).toContain('size-8') + }) + + it('keeps the explorer link visible when header is compressed', () => { + const html = renderToStaticMarkup( + + + + ) + + expect(html).toContain('View vault on block explorer') + expect(html).toContain('/address/') + }) + + it('hides cooldown information when there is no locked yvUSD deposit', () => { + const html = renderToStaticMarkup( + + + + ) + + expect(html).toContain('Your Deposits') + expect(html).not.toContain('IN COOLDOWN') + expect(html).not.toContain('WITHDRAWABLE') + expect(html).not.toContain('EXPIRED COOLDOWN') + }) + + it('does not enable locked yvBTC user-data reads while the locked address is a placeholder', () => { + renderToStaticMarkup( + + + + ) + + expect(useVaultUserDataMock).toHaveBeenCalledWith( + expect.objectContaining({ + vaultAddress: YVBTC_LOCKED_ADDRESS, + enabled: false + }) + ) + }) +}) diff --git a/src/components/pages/vaults/components/detail/VaultDetailsHeader.tsx b/src/components/pages/vaults/components/detail/VaultDetailsHeader.tsx index e0b3e7b4b..69f50e468 100755 --- a/src/components/pages/vaults/components/detail/VaultDetailsHeader.tsx +++ b/src/components/pages/vaults/components/detail/VaultDetailsHeader.tsx @@ -3,11 +3,18 @@ import { VaultForwardAPY } from '@pages/vaults/components/table/VaultForwardAPY' import { VaultHistoricalAPY } from '@pages/vaults/components/table/VaultHistoricalAPY' import { VaultTVL } from '@pages/vaults/components/table/VaultTVL' import { WidgetTabs } from '@pages/vaults/components/widget' +import { + formatDuration, + parseCooldownStatus, + resolveCooldownWindowState, + resolveYvUsdCooldownSummary +} from '@pages/vaults/components/widget/yvUSD/cooldownUtils' import { YvUsdApyTooltipContent, YvUsdTvlTooltipContent } from '@pages/vaults/components/yvUSD/YvUsdBreakdown' import { YvUsdHeaderBanner } from '@pages/vaults/components/yvUSD/YvUsdHeaderBanner' import { getVaultView, type TKongVaultInput } from '@pages/vaults/domain/kongVaultSelectors' import { useHeaderCompression } from '@pages/vaults/hooks/useHeaderCompression' -import { useVaultUserData } from '@pages/vaults/hooks/useVaultUserData' +import { useVaultUserData, type VaultUserData } from '@pages/vaults/hooks/useVaultUserData' +import { useYvBtcVaults } from '@pages/vaults/hooks/useYvBtcVaults' import { useYvUsdVaults } from '@pages/vaults/hooks/useYvUsdVaults' import { getYvUsdTvlBreakdown } from '@pages/vaults/hooks/useYvUsdVaults.helpers' import type { WidgetActionType } from '@pages/vaults/types' @@ -21,6 +28,7 @@ import { MIGRATABLE_TAG_DESCRIPTION, RETIRED_TAG_DESCRIPTION } from '@pages/vaults/utils/vaultTagCopy' +import { isYvBtcVault } from '@pages/vaults/utils/yvBtc' import { getYvUsdInfinifiPointsNote, getYvUsdSharePrice, @@ -42,11 +50,24 @@ import { TokenLogo } from '@shared/components/TokenLogo' import { Tooltip } from '@shared/components/Tooltip' import { useWeb3 } from '@shared/contexts/useWeb3' import { useYearn } from '@shared/contexts/useYearn' +import { yvUsdLockedVaultAbi } from '@shared/contracts/abi/yvUsdLockedVault.abi' +import { useReadContract } from '@shared/hooks/useAppWagmi' +import { useChainTimestamp } from '@shared/hooks/useChainTimestamp' import { IconInfinifiPoints } from '@shared/icons/IconInfinifiPoints' import { IconLinkOut } from '@shared/icons/IconLinkOut' import { IconLock } from '@shared/icons/IconLock' import { IconLockOpen } from '@shared/icons/IconLockOpen' -import { cl, formatApyDisplay, formatUSD, isZero, SELECTOR_BAR_STYLES, toAddress, toNormalizedBN } from '@shared/utils' +import { + cl, + formatApyDisplay, + formatTAmount, + formatUSD, + isZero, + isZeroAddress, + SELECTOR_BAR_STYLES, + toAddress, + toNormalizedBN +} from '@shared/utils' import { getVaultName } from '@shared/utils/helpers' import { getNetwork } from '@shared/utils/wagmi/utils' import type { ReactElement, Ref } from 'react' @@ -61,6 +82,23 @@ function noopWidgetModeChange(_mode: WidgetActionType): void {} function noopSelectSection(_key: string): void {} +const COMPRESSED_TITLE_FULL_SIZE_CLASS = 'md:text-[30px] md:leading-9' +const COMPRESSED_TITLE_COMPACT_SIZE_CLASS = 'md:text-[20px] md:leading-6' +const HEADER_METRIC_ITEM_CLASS = 'min-h-0' +const HEADER_METRIC_HEADER_CLASS = 'min-h-4' +const HEADER_METRIC_VALUE_CLASS = 'flex min-h-[30px] items-center' + +type TDualCooldownMetric = { + headerLabel: string + primaryValueUsd: number + timerLabel: string + statusLabel: string + amountLabel: string + amountValue: bigint + amountDecimals: number + amountSymbol: string +} + function getVaultProductTypeLabel(listKind: ReturnType): string { if (listKind === 'allocator' || listKind === 'strategy') { return 'Single Asset' @@ -108,22 +146,12 @@ function getVaultKindLabel(kindType: TVaultKindType, fallbackKind: string | null return fallbackKind ?? undefined } -function getVaultLogoSize({ isCompressed, isYvUsd }: { isCompressed: boolean; isYvUsd: boolean }): number { - if (isYvUsd) { - return 40 - } - +function getVaultLogoSize({ isCompressed }: { isCompressed: boolean }): number { return isCompressed ? 32 : 40 } -function getVaultLogoContainerSizeClassName({ - isCompressed, - isYvUsd -}: { - isCompressed: boolean - isYvUsd: boolean -}): string { - if (isYvUsd || !isCompressed) { +function getVaultLogoContainerSizeClassName({ isCompressed }: { isCompressed: boolean }): string { + if (!isCompressed) { return 'size-10' } @@ -147,7 +175,6 @@ function VaultHeaderIdentity({ }): ReactElement { const currentVault = getVaultView(currentVaultInput) const chainName = getNetwork(currentVault.chainID).name - const isYvUsd = isYvUsdVault(currentVault) const tokenLogoSrc = getVaultPrimaryLogoSrc(currentVault) const chainLogoSrc = `${import.meta.env.VITE_BASE_YEARN_ASSETS_URI}/chains/${currentVault.chainID}/logo-32.png` const explorerBase = getNetwork(currentVault.chainID).defaultBlockExplorer @@ -169,10 +196,11 @@ function VaultHeaderIdentity({ const shouldShowMetadata = showChainChip || showCategoryChip || showKindChip || Boolean(productTypeLabel) || isMigratable || isRetired const [isTitleClipped, setIsTitleClipped] = useState(false) - const titleRef = useRef(null) + const titleSlotRef = useRef(null) + const titleMeasureRef = useRef(null) const vaultName = getVaultName(currentVault) - const tokenLogoSize = getVaultLogoSize({ isCompressed, isYvUsd }) - const tokenLogoContainerSizeClassName = getVaultLogoContainerSizeClassName({ isCompressed, isYvUsd }) + const tokenLogoSize = getVaultLogoSize({ isCompressed }) + const tokenLogoContainerSizeClassName = getVaultLogoContainerSizeClassName({ isCompressed }) useEffect(() => { // Preload chain logo so it appears instantly when the chip mounts @@ -187,17 +215,34 @@ function VaultHeaderIdentity({ } const measure = (): void => { - if (!titleRef.current) return - setIsTitleClipped(titleRef.current.scrollWidth > titleRef.current.clientWidth) + if (!titleSlotRef.current || !titleMeasureRef.current) { + return + } + + setIsTitleClipped(titleMeasureRef.current.scrollWidth > titleSlotRef.current.clientWidth) } measure() - window.addEventListener('resize', measure) + if (typeof ResizeObserver === 'undefined') { + window.addEventListener('resize', measure) + return () => { + window.removeEventListener('resize', measure) + } + } + + const observer = new ResizeObserver(measure) + const titleSlotElement = titleSlotRef.current + const titleMeasureElement = titleMeasureRef.current + if (!titleSlotElement || !titleMeasureElement) { + return () => observer.disconnect() + } + observer.observe(titleSlotElement) + observer.observe(titleMeasureElement) return () => { - window.removeEventListener('resize', measure) + observer.disconnect() } - }, [isCompressed]) + }, [isCompressed, vaultName]) return (
) : null}
-
-
- - {vaultName} - - {isCompressed && isTitleClipped ? ( - - ) : null} - {!isCompressed && explorerHref ? ( - - - - ) : null} +
+
+ {isCompressed ? ( +
+
+ + {vaultName} + + + {vaultName} + +
+ {explorerHref ? ( + + + + ) : null} +
+ ) : ( + <> +
+ + {vaultName} + +
+ {explorerHref ? ( + + + + ) : null} + + )}
@@ -317,19 +397,6 @@ function VaultHeaderIdentity({ tooltipDescription={MIGRATABLE_TAG_DESCRIPTION} /> ) : null} - {isCompressed && explorerHref ? ( - - - - ) : null}
) : null}
@@ -407,33 +474,39 @@ function VaultOverviewCard({ const listKind = deriveListKind(currentVault) const isFactoryVault = listKind === 'factory' const isYvUsd = isYvUsdVault(currentVault) + const isYvBtc = isYvBtcVault(currentVault) + const isDualVariantVault = isYvUsd || isYvBtc const [internalYvUsdApyVariant, setInternalYvUsdApyVariant] = useState('locked') - const { metrics: yvUsdMetrics, unlockedVault, lockedVault } = useYvUsdVaults() + const { metrics: yvUsdMetrics, unlockedVault: yvUsdUnlockedVault, lockedVault: yvUsdLockedVault } = useYvUsdVaults() + const { metrics: yvBtcMetrics, unlockedVault: yvBtcUnlockedVault, lockedVault: yvBtcLockedVault } = useYvBtcVaults() + const dualMetrics = isYvBtc ? yvBtcMetrics : yvUsdMetrics + const unlockedVault = isYvBtc ? yvBtcUnlockedVault : yvUsdUnlockedVault + const lockedVault = isYvBtc ? yvBtcLockedVault : yvUsdLockedVault const isControlledYvUsdApyVariant = controlledYvUsdApyVariant !== undefined const yvUsdApyVariant = isControlledYvUsdApyVariant ? controlledYvUsdApyVariant : internalYvUsdApyVariant const unlockedForwardApy = - yvUsdMetrics?.unlocked.apy ?? (currentVault.apr?.forwardAPR?.netAPR || currentVault.apr?.netAPR || 0) - const lockedForwardApy = yvUsdMetrics?.locked.apy ?? lockedVault?.apr?.forwardAPR?.netAPR ?? 0 + dualMetrics?.unlocked.apy ?? (currentVault.apr?.forwardAPR?.netAPR || currentVault.apr?.netAPR || 0) + const lockedForwardApy = dualMetrics?.locked.apy ?? lockedVault?.apr?.forwardAPR?.netAPR ?? 0 const unlockedMonthly = unlockedVault?.apr?.points?.monthAgo ?? currentVault.apr.points.monthAgo const unlockedWeekly = unlockedVault?.apr?.points?.weekAgo ?? currentVault.apr.points.weekAgo const unlockedHistorical = getYvUsdHistoricalValue(unlockedMonthly, unlockedWeekly) const lockedMonthly = lockedVault?.apr?.points?.monthAgo ?? 0 const lockedWeekly = lockedVault?.apr?.points?.weekAgo ?? 0 const lockedHistorical = getYvUsdHistoricalValue(lockedMonthly, lockedWeekly) - const totalTvl = currentVault.tvl?.tvl ?? unlockedVault?.tvl?.tvl ?? yvUsdMetrics?.unlocked.tvl ?? 0 - const lockedTvl = lockedVault?.tvl?.tvl ?? yvUsdMetrics?.locked.tvl ?? 0 + const totalTvl = currentVault.tvl?.tvl ?? unlockedVault?.tvl?.tvl ?? dualMetrics?.unlocked.tvl ?? 0 + const lockedTvl = lockedVault?.tvl?.tvl ?? dualMetrics?.locked.tvl ?? 0 const tvlBreakdown = getYvUsdTvlBreakdown({ totalTvl, lockedTvl }) const unlockedTvl = tvlBreakdown.unlockedTvl const combinedTvl = tvlBreakdown.totalTvl const isLockedApyVariant = yvUsdApyVariant === 'locked' const selectedForwardApy = isLockedApyVariant ? lockedForwardApy : unlockedForwardApy const selectedHistoricalApy = isLockedApyVariant ? lockedHistorical : unlockedHistorical - const hasInfinifiPoints = Boolean(yvUsdMetrics?.locked.hasInfinifiPoints || yvUsdMetrics?.unlocked.hasInfinifiPoints) + const hasInfinifiPoints = Boolean(dualMetrics?.locked.hasInfinifiPoints || dualMetrics?.unlocked.hasInfinifiPoints) const infinifiPointsNote = hasInfinifiPoints ? getYvUsdInfinifiPointsNote() : undefined const selectedApyIcon = isLockedApyVariant ? ( - + ) : ( - + ) const apyToggleLabel = isLockedApyVariant ? 'Switch to unlocked APY display' : 'Switch to locked APY display' const toggleApyVariant = (): void => { @@ -445,20 +518,22 @@ function VaultOverviewCard({ setInternalYvUsdApyVariant(nextVariant) } const renderYvUsdApyValue = (value: number): ReactElement => ( - + + + {hasInfinifiPoints ? : null} + {formatApyDisplay(value)} + - {hasInfinifiPoints ? : null} - {formatApyDisplay(value)} ) - const yvUsdEstApyTooltip = isYvUsd ? ( + const yvUsdEstApyTooltip = isDualVariantVault ? ( ) : undefined - const yvUsdHistoricalApyTooltip = isYvUsd ? ( + const yvUsdHistoricalApyTooltip = isDualVariantVault ? ( ) : undefined - const yvUsdTvlTooltip = isYvUsd ? ( + const yvUsdTvlTooltip = isDualVariantVault ? ( , - value: isYvUsd ? ( - + value: isDualVariantVault ? ( + {renderYvUsdApyValue(selectedForwardApy)} ) : ( @@ -503,9 +583,9 @@ function VaultOverviewCard({ { key: 'historical-apy', header: , - value: isYvUsd ? ( + value: isDualVariantVault ? ( , - value: isYvUsd ? ( + value: isDualVariantVault ? ( ), - footnote: isYvUsd ? ( + footnote: isDualVariantVault ? ( yvUsdTvlTooltip ) : (

@@ -567,8 +647,11 @@ function VaultOverviewCard({ items={metrics} className={cl( 'rounded-b-none', - isCompressed ? 'border-l border-border rounded-l-none' : 'border border-border' + isCompressed ? 'border-l border-border rounded-l-none' : 'px-2 border border-border' )} + itemClassName={HEADER_METRIC_ITEM_CLASS} + headerClassName={HEADER_METRIC_HEADER_CLASS} + valueClassName={HEADER_METRIC_VALUE_CLASS} footnoteDisplay={'tooltip'} />

@@ -600,20 +683,185 @@ function YvUsdUserHoldingsCard({ chainId: YVUSD_CHAIN_ID, account }) + const { data: rawCooldownStatus } = useReadContract({ + address: YVUSD_LOCKED_ADDRESS, + abi: yvUsdLockedVaultAbi, + functionName: 'getCooldownStatus', + args: account ? [toAddress(account)] : undefined, + chainId: YVUSD_CHAIN_ID, + query: { + enabled: !!account, + refetchInterval: account ? 30_000 : false + } + }) + const { data: rawAvailableWithdrawLimit } = useReadContract({ + address: YVUSD_LOCKED_ADDRESS, + abi: yvUsdLockedVaultAbi, + functionName: 'availableWithdrawLimit', + args: account ? [toAddress(account)] : undefined, + chainId: YVUSD_CHAIN_ID, + query: { + enabled: !!account, + refetchInterval: account ? 30_000 : false + } + }) + const cooldownStatus = parseCooldownStatus(rawCooldownStatus) + const hasActiveCooldown = cooldownStatus.shares > 0n + const availableWithdrawLimit = typeof rawAvailableWithdrawLimit === 'bigint' ? rawAvailableWithdrawLimit : 0n + const { timestamp: nowTimestamp } = useChainTimestamp({ + chainId: YVUSD_CHAIN_ID, + enabled: Boolean(account && hasActiveCooldown) + }) + const cooldownWindowState = resolveCooldownWindowState({ + hasActiveCooldown, + nowTimestamp, + cooldownEnd: cooldownStatus.cooldownEnd, + windowEnd: cooldownStatus.windowEnd, + availableWithdrawLimit + }) + const cooldownRemainingSeconds = cooldownWindowState.isCooldownActive + ? Math.max(cooldownStatus.cooldownEnd - nowTimestamp, 0) + : 0 + const withdrawalWindowRemainingSeconds = cooldownWindowState.isWithdrawalWindowOpen + ? Math.max(cooldownStatus.windowEnd - nowTimestamp, 0) + : 0 + const cooldownSummary = resolveYvUsdCooldownSummary({ + hasActiveCooldown, + isCooldownActive: cooldownWindowState.isCooldownActive, + isWithdrawalWindowOpen: cooldownWindowState.isWithdrawalWindowOpen, + isCooldownWindowExpired: cooldownWindowState.isCooldownWindowExpired + }) + + const sharesUnderCooldown = hasActiveCooldown ? cooldownStatus.shares : 0n + const lockedVaultDecimals = lockedUserData.vaultToken?.decimals ?? 18 + const lockedAssetDecimals = lockedUserData.assetToken?.decimals ?? 18 + const lockedAssetSymbol = lockedUserData.assetToken?.symbol ?? 'yvUSD' + const assetsUnderCooldown = + sharesUnderCooldown > 0n && lockedUserData.pricePerShare > 0n + ? (sharesUnderCooldown * lockedUserData.pricePerShare) / 10n ** BigInt(lockedVaultDecimals) + : 0n + const unlockedAssetPrice = + getPrice({ address: unlockedAssetAddress, chainID: YVUSD_CHAIN_ID }).normalized || unlockedVault?.tvl.price || 0 + const unlockedSharePrice = getYvUsdSharePrice(unlockedVault, unlockedAssetPrice) + const hasLockedDeposits = lockedUserData.depositedValue > 0n || hasActiveCooldown + const cooldownValueUsd = toNormalizedBN(assetsUnderCooldown, lockedAssetDecimals).normalized * unlockedSharePrice + const withdrawableValueUsd = + toNormalizedBN(availableWithdrawLimit, lockedAssetDecimals).normalized * unlockedSharePrice + const cooldownStatusLabel = cooldownSummary?.label ?? 'No active cooldown' + const cooldownPrimaryValueUsd = cooldownWindowState.isWithdrawalWindowOpen ? withdrawableValueUsd : cooldownValueUsd + const cooldownHeaderLabel = cooldownWindowState.isWithdrawalWindowOpen + ? 'WITHDRAWABLE' + : cooldownWindowState.isCooldownWindowExpired + ? 'EXPIRED COOLDOWN' + : 'IN COOLDOWN' + const cooldownTimerLabel = cooldownWindowState.isCooldownActive + ? formatDuration(cooldownRemainingSeconds) + : cooldownWindowState.isWithdrawalWindowOpen && withdrawalWindowRemainingSeconds > 0 + ? `${formatDuration(withdrawalWindowRemainingSeconds)} left` + : cooldownWindowState.isCooldownWindowExpired + ? 'Window closed' + : 'Not started' + const cooldownAmountLabel = cooldownWindowState.isWithdrawalWindowOpen ? 'Withdrawable now' : 'In cooldown' + const cooldownMetric = hasLockedDeposits + ? { + headerLabel: cooldownHeaderLabel, + primaryValueUsd: cooldownPrimaryValueUsd, + timerLabel: cooldownTimerLabel, + statusLabel: cooldownStatusLabel, + amountLabel: cooldownAmountLabel, + amountValue: cooldownWindowState.isWithdrawalWindowOpen ? availableWithdrawLimit : assetsUnderCooldown, + amountDecimals: lockedAssetDecimals, + amountSymbol: lockedAssetSymbol + } + : undefined + + return ( + + ) +} + +function YvBtcUserHoldingsCard({ + isCompressed, + includeTourAttributes = true +}: { + isCompressed: boolean + includeTourAttributes?: boolean +}): ReactElement { + const { address } = useWeb3() + const { getPrice } = useYearn() + const { unlockedVault, lockedVault } = useYvBtcVaults() + const account = address ? toAddress(address) : undefined + const unlockedAssetAddress = toAddress(unlockedVault?.token.address) + const isLockedVaultLive = !isZeroAddress(toAddress(lockedVault?.address)) + + const unlockedUserData = useVaultUserData({ + vaultAddress: toAddress(unlockedVault?.address), + assetAddress: unlockedAssetAddress, + chainId: unlockedVault.chainID, + account + }) + const lockedUserData = useVaultUserData({ + vaultAddress: toAddress(lockedVault?.address), + assetAddress: unlockedAssetAddress, + chainId: lockedVault.chainID, + account, + enabled: isLockedVaultLive + }) + const unlockedAssetPrice = + getPrice({ address: unlockedAssetAddress, chainID: unlockedVault.chainID }).normalized || + unlockedVault?.tvl.price || + 0 + const unlockedSharePrice = + unlockedAssetPrice * (Number(unlockedUserData.pricePerShare) / 10 ** unlockedVault.decimals) + + return ( + + ) +} + +function DualVariantUserHoldingsCard({ + isCompressed, + includeTourAttributes = true, + unlockedUserData, + lockedUserData, + unlockedValueUsdMultiplier, + lockedValueUsdMultiplier, + cooldownMetric +}: { + isCompressed: boolean + includeTourAttributes?: boolean + unlockedUserData: VaultUserData + lockedUserData: VaultUserData + unlockedValueUsdMultiplier: number + lockedValueUsdMultiplier: number + cooldownMetric?: TDualCooldownMetric +}): ReactElement { const unlockedAmount = toNormalizedBN( unlockedUserData.depositedValue, - unlockedUserData.assetToken?.decimals ?? 6 + unlockedUserData.assetToken?.decimals ?? 18 ).normalized const lockedAmount = toNormalizedBN( lockedUserData.depositedValue, lockedUserData.assetToken?.decimals ?? 18 ).normalized - const unlockedAssetPrice = - getPrice({ address: unlockedAssetAddress, chainID: YVUSD_CHAIN_ID }).normalized || unlockedVault?.tvl.price || 0 - const unlockedSharePrice = getYvUsdSharePrice(unlockedVault, unlockedAssetPrice) - const unlockedValueUsd = unlockedAmount * unlockedAssetPrice - const lockedValueUsd = lockedAmount * unlockedSharePrice + const unlockedValueUsd = unlockedAmount * unlockedValueUsdMultiplier + const lockedValueUsd = lockedAmount * lockedValueUsdMultiplier const totalValueUsd = unlockedValueUsd + lockedValueUsd const sections: TMetricBlock[] = [ @@ -658,7 +906,57 @@ function YvUsdUserHoldingsCard({
) - } + }, + ...(cooldownMetric + ? [ + { + key: 'cooldowns', + header: ( + + ), + value: ( + + {formatUSD(cooldownMetric.primaryValueUsd)} + + {cooldownMetric.timerLabel} + + + ), + footnote: ( +
+
+ {'Status'} + {cooldownMetric.statusLabel} +
+
+ {cooldownMetric.amountLabel} + + {`${formatTAmount({ + value: cooldownMetric.amountValue, + decimals: cooldownMetric.amountDecimals + })} ${cooldownMetric.amountSymbol}`} + +
+
+ {'Timer'} + {cooldownMetric.timerLabel} +
+
+ ) + } satisfies TMetricBlock + ] + : []) ] return ( @@ -667,8 +965,11 @@ function YvUsdUserHoldingsCard({ items={sections} className={cl( 'rounded-b-none', - isCompressed ? 'rounded-tl-lg border-t border-x border-border' : 'border-t border-x border-border' + isCompressed ? 'px-2 rounded-tl-lg border-t border-x border-border' : 'px-2 border-t border-x border-border' )} + itemClassName={HEADER_METRIC_ITEM_CLASS} + headerClassName={HEADER_METRIC_HEADER_CLASS} + valueClassName={HEADER_METRIC_VALUE_CLASS} footnoteDisplay={'tooltip'} />
@@ -692,6 +993,9 @@ function UserHoldingsCard({ if (isYvUsdVault(currentVault)) { return } + if (isYvBtcVault(currentVault)) { + return + } const depositedAmount = toNormalizedBN(depositedValue, currentVault.token.decimals) const depositedValueUSD = depositedAmount.normalized * tokenPrice @@ -733,7 +1037,7 @@ function UserHoldingsCard({ items={sections} className={cl( 'rounded-b-none', - isCompressed ? 'rounded-tl-lg border-t border-x border-border' : 'border-t border-x border-border' + isCompressed ? 'px-2 rounded-tl-lg border-t border-x border-border' : 'px-2 border-t border-x border-border' )} footnoteDisplay={'tooltip'} /> @@ -812,24 +1116,26 @@ export function VaultDetailsHeaderPresentation({ // 'border border-border', )} > -
- -
- +
+ +
+ +
{sectionTabs.length > 0 ? ( -
+
void + onCompressionStateChange?: (state: { isCompressed: boolean; isForceCompressed: boolean }) => void }): ReactElement { const [forceCompressed, setForceCompressed] = useState(false) const { isCompressed } = useHeaderCompression({ enabled: isCollapsibleMode, forceCompressed }) @@ -951,5 +1259,9 @@ export function VaultDetailsHeader({ onCompressionChange?.(isCompressed) }, [isCompressed, onCompressionChange]) + useEffect(() => { + onCompressionStateChange?.({ isCompressed, isForceCompressed: forceCompressed }) + }, [isCompressed, forceCompressed, onCompressionStateChange]) + return } diff --git a/src/components/pages/vaults/components/detail/VaultInfoSection.tsx b/src/components/pages/vaults/components/detail/VaultInfoSection.tsx index 4c2f9cef8..053beba8f 100644 --- a/src/components/pages/vaults/components/detail/VaultInfoSection.tsx +++ b/src/components/pages/vaults/components/detail/VaultInfoSection.tsx @@ -11,8 +11,10 @@ import { type TKongVaultInput } from '@pages/vaults/domain/kongVaultSelectors' import { YBOLD_VAULT_ADDRESS } from '@pages/vaults/domain/normalizeVault' +import { useYvBtcVaults } from '@pages/vaults/hooks/useYvBtcVaults' import { useYvUsdVaults } from '@pages/vaults/hooks/useYvUsdVaults' import { KONG_REST_BASE } from '@pages/vaults/utils/kongRest' +import { isYvBtcAddress, YVBTC_LOCKED_ADDRESS, YVBTC_UNLOCKED_ADDRESS } from '@pages/vaults/utils/yvBtc' import { isYvUsdAddress, YVUSD_LOCKED_ADDRESS, YVUSD_UNLOCKED_ADDRESS } from '@pages/vaults/utils/yvUsd' import { IconCopy } from '@shared/icons/IconCopy' import { IconLinkOut } from '@shared/icons/IconLinkOut' @@ -227,6 +229,29 @@ function YvUsdPricePerShareRows(): ReactElement { ) } +function YvBtcPricePerShareRows(): ReactElement { + const { unlockedVault, lockedVault } = useYvBtcVaults() + const unlockedPricePerShare = getVaultAPR(unlockedVault).pricePerShare.today + const lockedPricePerShare = getVaultAPR(lockedVault).pricePerShare.today + + return ( + <> +
+

{'Unlocked Price Per Share'}

+

+ {unlockedPricePerShare} +

+
+
+

{'Locked Price Per Share'}

+

+ {lockedPricePerShare} +

+
+ + ) +} + export function VaultInfoSection({ currentVault, inceptTime @@ -237,6 +262,8 @@ export function VaultInfoSection({ const chainID = getVaultChainID(currentVault) const vaultAddress = getVaultAddress(currentVault) const isYvUsd = isYvUsdAddress(vaultAddress) + const isYvBtc = isYvBtcAddress(vaultAddress) + const isDualVariantVault = isYvUsd || isYvBtc const token = getVaultToken(currentVault) const staking = getVaultStaking(currentVault) const info = getVaultInfo(currentVault) @@ -279,15 +306,15 @@ export function VaultInfoSection({ return (
- {isYvUsd ? ( + {isDualVariantVault ? ( <> @@ -362,6 +389,8 @@ export function VaultInfoSection({ {isYvUsd ? ( + ) : isYvBtc ? ( + ) : (

{'Current Price Per Share'}

diff --git a/src/components/pages/vaults/components/detail/VaultRiskSection.test.tsx b/src/components/pages/vaults/components/detail/VaultRiskSection.test.tsx new file mode 100644 index 000000000..684d11d03 --- /dev/null +++ b/src/components/pages/vaults/components/detail/VaultRiskSection.test.tsx @@ -0,0 +1,29 @@ +import { YVBTC_UNLOCKED_ADDRESS } from '@pages/vaults/utils/yvBtc' +import { renderToStaticMarkup } from 'react-dom/server' +import { describe, expect, it } from 'vitest' +import { VaultRiskSection } from './VaultRiskSection' + +describe('VaultRiskSection', () => { + it('temporarily reuses yvUSD risk information for yvBTC', () => { + const html = renderToStaticMarkup( + + ) + + expect(html).toContain('Leverage Looping') + expect(html).toContain('Duration and PT Strategies') + expect(html).toContain('Cross-Chain Routing') + }) +}) diff --git a/src/components/pages/vaults/components/detail/VaultRiskSection.tsx b/src/components/pages/vaults/components/detail/VaultRiskSection.tsx index 34e25ce52..b4ce4664e 100644 --- a/src/components/pages/vaults/components/detail/VaultRiskSection.tsx +++ b/src/components/pages/vaults/components/detail/VaultRiskSection.tsx @@ -1,6 +1,7 @@ import { VaultRiskScoreTag } from '@pages/vaults/components/table/VaultRiskScoreTag' import { getVaultInfo, getVaultKind, type TKongVaultInput } from '@pages/vaults/domain/kongVaultSelectors' import { deriveListKind } from '@pages/vaults/utils/vaultListFacets' +import { isYvBtcVault } from '@pages/vaults/utils/yvBtc' import { isYvUsdVault, YVUSD_RISK_SCORE_ITEMS } from '@pages/vaults/utils/yvUsd' import { cl } from '@shared/utils' import { type ReactElement, useState } from 'react' @@ -61,15 +62,19 @@ export function VaultRiskSection({ currentVault }: { currentVault: TKongVaultInp const info = getVaultInfo(currentVault) const hasRiskScore = (info.riskScore || []).reduce((sum, score) => sum + score, 0) - if (isYvUsdVault(currentVault)) { - return + if (isYvUsdVault(currentVault) || isYvBtcVault(currentVault)) { + return } return } -function YvUsdRiskScore(): ReactElement { +function YvUsdRiskScore({ vaultLabel }: { vaultLabel: 'yvUSD' | 'yvBTC' }): ReactElement { const [openIndex, setOpenIndex] = useState(null) + const riskScoreItems = YVUSD_RISK_SCORE_ITEMS.map((item) => ({ + ...item, + explanation: vaultLabel === 'yvBTC' ? item.explanation.replaceAll('yvUSD', 'yvBTC') : item.explanation + })) const toggleItem = (index: number): void => { setOpenIndex((current) => (current === index ? null : index)) @@ -78,7 +83,7 @@ function YvUsdRiskScore(): ReactElement { return (
- {YVUSD_RISK_SCORE_ITEMS.map((item, index) => ( + {riskScoreItems.map((item, index) => ( } -export type { TVaultsFiltersPanelSection, TFilterChecklistOption, TFilterToggleOption } +export type { TFilterChecklistOption, TFilterToggleOption, TVaultsFiltersPanelSection } diff --git a/src/components/pages/vaults/components/list/VaultsAuxiliaryList.tsx b/src/components/pages/vaults/components/list/VaultsAuxiliaryList.tsx index e8ba014e3..266e0e0bb 100644 --- a/src/components/pages/vaults/components/list/VaultsAuxiliaryList.tsx +++ b/src/components/pages/vaults/components/list/VaultsAuxiliaryList.tsx @@ -26,10 +26,12 @@ type TVaultsAuxiliaryListProps = { activeChains?: number[] activeCategories?: string[] activeProductType?: 'v3' | 'lp' | 'all' + activeFeeStructureKey?: string | null onToggleChain?: (chainId: number) => void onToggleCategory?: (category: string) => void onToggleType?: (type: string) => void onToggleVaultType?: (type: 'v3' | 'lp') => void + onToggleFeeStructure?: (feeStructureKey: string) => void showStrategies?: boolean shouldCollapseChips?: boolean expandedVaultKeys?: Record @@ -52,10 +54,12 @@ export function VaultsAuxiliaryList({ activeChains, activeCategories, activeProductType, + activeFeeStructureKey, onToggleChain, onToggleCategory, onToggleType, onToggleVaultType, + onToggleFeeStructure, showStrategies, shouldCollapseChips, expandedVaultKeys, @@ -89,10 +93,12 @@ export function VaultsAuxiliaryList({ activeChains={activeChains} activeCategories={activeCategories} activeProductType={activeProductType} + activeFeeStructureKey={activeFeeStructureKey} onToggleChain={onToggleChain} onToggleCategory={onToggleCategory} onToggleType={onToggleType} onToggleVaultType={onToggleVaultType} + onToggleFeeStructure={onToggleFeeStructure} shouldCollapseChips={shouldCollapseChips} showStrategies={showStrategies} isExpanded={isExpanded} diff --git a/src/components/pages/vaults/components/list/VaultsListChip.tsx b/src/components/pages/vaults/components/list/VaultsListChip.tsx index 010cd86ef..be610bb04 100644 --- a/src/components/pages/vaults/components/list/VaultsListChip.tsx +++ b/src/components/pages/vaults/components/list/VaultsListChip.tsx @@ -39,7 +39,11 @@ export function VaultsListChip({ const shouldShowIconInChip = Boolean(icon) && showIconInChip const shouldCollapse = isCollapsed && shouldShowIconInChip const iconNode = icon ? ( - {icon} + + {icon} + ) : null const chip = ( @@ -48,8 +52,9 @@ export function VaultsListChip({ className={cl( 'inline-flex items-center rounded-lg border border-border px-1 py-0.5 text-xs font-medium transition-colors', 'bg-surface-secondary text-text-primary/70', - 'data-[active=true]:bg-surface-tertiary/80 data-[active=true]:text-text-primary', + 'data-[active=true]:border-primary/80 data-[active=true]:bg-surface-tertiary/80 data-[active=true]:text-text-primary', isInteractive ? 'cursor-pointer hover:bg-surface-tertiary/80 hover:text-text-primary' : 'cursor-default', + isInteractive ? 'data-[active=true]:hover:border-primary/80 data-[active=true]:hover:text-text-primary' : '', shouldCollapse ? 'gap-0' : 'gap-1' )} data-active={isActive} diff --git a/src/components/pages/vaults/components/list/VaultsListRow.test.tsx b/src/components/pages/vaults/components/list/VaultsListRow.test.tsx index f795252da..3a41c17b0 100644 --- a/src/components/pages/vaults/components/list/VaultsListRow.test.tsx +++ b/src/components/pages/vaults/components/list/VaultsListRow.test.tsx @@ -1,6 +1,9 @@ import type { TKongVaultInput } from '@pages/vaults/domain/kongVaultSelectors' +import { getVaultFeeStructureKey } from '@pages/vaults/utils/vaultFees' +import { YVBTC_UNLOCKED_ADDRESS } from '@pages/vaults/utils/yvBtc' import { YVUSD_UNLOCKED_ADDRESS } from '@pages/vaults/utils/yvUsd' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import type { ComponentProps } from 'react' import { renderToStaticMarkup } from 'react-dom/server' import { MemoryRouter } from 'react-router' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -65,13 +68,13 @@ vi.mock('@pages/vaults/components/table/VaultRiskScoreTag', () => ({ RiskScoreInlineDetails: () =>
{'Risk details'}
})) -function renderRowHtml(vault: TKongVaultInput): string { +function renderRowHtml(vault: TKongVaultInput, props?: Partial>): string { const queryClient = new QueryClient() return renderToStaticMarkup( - + ) @@ -164,6 +167,8 @@ describe('VaultsListRow', () => { expect(html).toContain('Up to') expect(html).toContain('9.00%') expect(html).toContain('inline-flex flex-col items-start') + expect(html).toContain('style="width:40px;height:40px"') + expect(html).not.toContain('style="width:48px;height:48px"') }) it('formats yvUSD locked APY with shared significant-digit rounding in the list row', () => { @@ -464,4 +469,125 @@ describe('VaultsListRow', () => { expect(firstCall?.currentVault?.performance?.oracle?.netAPY).toBe(0.03431466938555827) expect(mockUseVaultSnapshot).not.toHaveBeenCalled() }) + + it('does not show a temporary override chip for yvBTC rows before launch', () => { + const vault = { + version: '3.0.4', + chainID: 1, + address: YVBTC_UNLOCKED_ADDRESS, + name: 'BTC yVault', + symbol: 'yvBTC', + category: 'Volatile', + kind: null, + token: { + address: '0x0000000000000000000000000000000000000002', + symbol: 'cbBTC', + decimals: 8 + }, + tvl: { + tvl: 1234, + totalAssets: 1234567 + }, + info: { + riskLevel: 3 + } + } as unknown as TKongVaultInput + + const html = renderRowHtml(vault) + + expect(html).not.toContain('Override') + expect(html).not.toContain('Temporary visibility override') + }) + + it('renders the fees chip as an active exact fee-structure filter', () => { + const vault = { + chainId: 1, + address: '0xBe53A109B494E5c9f97b9Cd39Fe969BE68BF6204', + name: 'USDC-1 yVault', + symbol: 'yvUSDC-1', + apiVersion: '3.0.0', + decimals: 18, + asset: { + address: '0x0000000000000000000000000000000000000002', + name: 'USDC', + symbol: 'USDC', + decimals: 6 + }, + tvl: 1000, + performance: { + oracle: { + apr: 0.0375, + apy: 0.038197965598908645, + netAPR: 0.03375, + netAPY: 0.03431466938555827 + }, + estimated: null, + historical: { + net: 0.03393840219361399, + weeklyNet: 0.04161091901014902, + monthlyNet: 0.03393840219361399, + inceptionNet: 0.04906534139769114 + } + }, + fees: { + managementFee: 0, + performanceFee: 0.1 + }, + category: 'Stablecoin', + type: 'Standard', + kind: 'Single Strategy', + v3: true, + yearn: true, + isRetired: false, + isHidden: false, + isBoosted: false, + isHighlighted: false, + strategiesCount: 1, + riskLevel: 1, + staking: null, + pricePerShare: '1000000000000000000' + } as unknown as TKongVaultInput + const feeStructureKey = getVaultFeeStructureKey(vault) + + const html = renderRowHtml(vault, { + activeFeeStructureKey: feeStructureKey + }) + + expect(html).toContain('Fees: 0% | 10%') + expect(html).toContain('aria-label="Filter by 0% management fee and 10% performance fee"') + expect(html).toMatch(/data-active="true"[^>]*>]*>Fees: 0% \| 10%<\/span>/) + }) + + it('renders the derived asset-category chip for LP vaults with protocol categories', () => { + const vault = { + version: '0.4.6', + chainID: 1, + address: '0x0000000000000000000000000000000000000001', + name: 'Curve USDC Factory Vault', + symbol: 'yvCurve-USDC', + category: 'Curve', + kind: null, + token: { + address: '0x0000000000000000000000000000000000000002', + name: 'Curve USDC LP', + symbol: 'crvUSDC', + decimals: 18 + }, + tvl: { + tvl: 1234, + totalAssets: 1234567 + }, + info: { + riskLevel: 2 + } + } as unknown as TKongVaultInput + + const html = renderRowHtml(vault, { + activeCategories: ['Stablecoin'] + }) + + expect(html).toContain('Curve') + expect(html).toContain('aria-label="Filter by Stablecoin"') + expect(html).toMatch(/data-active="true"[^>]*>]*>Stablecoin<\/span>/) + }) }) diff --git a/src/components/pages/vaults/components/list/VaultsListRow.tsx b/src/components/pages/vaults/components/list/VaultsListRow.tsx index 4a8a84e82..1d761c277 100755 --- a/src/components/pages/vaults/components/list/VaultsListRow.tsx +++ b/src/components/pages/vaults/components/list/VaultsListRow.tsx @@ -24,7 +24,12 @@ import { import { useYvUsdVaults } from '@pages/vaults/hooks/useYvUsdVaults' import { getYvUsdTvlBreakdown } from '@pages/vaults/hooks/useYvUsdVaults.helpers' import { KONG_REST_BASE } from '@pages/vaults/utils/kongRest' -import { deriveListKind } from '@pages/vaults/utils/vaultListFacets' +import { + formatFeeStructureFilterAriaLabel, + formatFeeStructureLabel, + getFeeStructureKeyFromFees +} from '@pages/vaults/utils/vaultFees' +import { deriveAssetCategory, deriveListKind } from '@pages/vaults/utils/vaultListFacets' import { getVaultPrimaryLogoSrc } from '@pages/vaults/utils/vaultLogo' import { getCategoryDescription, @@ -54,7 +59,7 @@ import { fetchWithSchema, getFetchQueryKey } from '@shared/hooks/useFetch' import { IconChevron } from '@shared/icons/IconChevron' import { IconEyeOff } from '@shared/icons/IconEyeOff' import { IconInfinifiPoints } from '@shared/icons/IconInfinifiPoints' -import { cl, formatAmount, formatApyDisplay, formatTvlDisplay, getVaultName, toAddress } from '@shared/utils' +import { cl, formatApyDisplay, formatTvlDisplay, getVaultName, toAddress } from '@shared/utils' import { PLAUSIBLE_EVENTS } from '@shared/utils/plausible' import { kongVaultSnapshotSchema } from '@shared/utils/schemas/kongVaultSnapshotSchema' import { getNetwork } from '@shared/utils/wagmi' @@ -224,7 +229,9 @@ type TVaultsListRowProps = { onToggleCategory?: (category: string) => void onToggleType?: (type: string) => void activeProductType?: 'v3' | 'lp' | 'all' + activeFeeStructureKey?: string | null onToggleVaultType?: (type: 'v3' | 'lp') => void + onToggleFeeStructure?: (feeStructureKey: string) => void showStrategies?: boolean shouldCollapseChips?: boolean isExpanded?: boolean @@ -250,7 +257,9 @@ function VaultsListRowComponent({ onToggleCategory, onToggleType, activeProductType, + activeFeeStructureKey, onToggleVaultType, + onToggleFeeStructure, showStrategies = false, shouldCollapseChips = false, isExpanded: isExpandedProp, @@ -385,18 +394,17 @@ function VaultsListRowComponent({ const showKindChip = showStrategies && Boolean(kindType) && (showAllocatorChip || kindType !== 'multi') const isKindActive = false const chainDescription = getChainDescription(chainID) + const assetCategory = deriveAssetCategory(currentVault) + const showAssetCategoryChip = productType === 'lp' && assetCategory !== vaultCategory + const assetCategoryDescription = getCategoryDescription(assetCategory) const categoryDescription = getCategoryDescription(vaultCategory) const productTypeDescription = getProductTypeDescription(listKind) const kindDescription = getKindDescription(kindType, kindLabel) const fees = apr?.fees + const feeStructureKey = getFeeStructureKeyFromFees(fees) const showFeesChip = Boolean(fees) && !isChipsCompressed - const feesChipLabel = fees - ? `Fees: ${formatAmount((fees.management || 0) * 100, 0, 2)}% | ${formatAmount( - (fees.performance || 0) * 100, - 0, - 2 - )}%` - : '' + const feesChipLabel = formatFeeStructureLabel(fees) + const isFeesChipActive = activeFeeStructureKey === feeStructureKey const retiredIcon = {'⚠️'} const holdingsIcon = (
@@ -560,7 +567,7 @@ function VaultsListRowComponent({ isCompareSelected ? `Remove ${vaultName} from comparison` : `Add ${vaultName} to comparison` } tabIndex={0} - className={cl('flex cursor-pointer items-center justify-center', isYvUsd ? 'ml-2' : '')} + className={'flex cursor-pointer items-center justify-center'} onClick={(event): void => { event.stopPropagation() event.preventDefault() @@ -591,17 +598,14 @@ function VaultsListRowComponent({
) : null}
- {isYvUsd ? ( - - ) : ( - - )} +
) : null} + {showAssetCategoryChip ? ( + onToggleCategory(assetCategory) : undefined} + onHoverChange={handleInteractiveHoverChange} + ariaLabel={`Filter by ${assetCategory}`} + /> + ) : null} {showProductTypeChip ? ( onToggleFeeStructure(feeStructureKey) : undefined} onHoverChange={handleInteractiveHoverChange} + ariaLabel={formatFeeStructureFilterAriaLabel(fees)} /> ) : null} {showKindChip && kindLabel ? ( @@ -917,6 +936,9 @@ function VaultsListRowComponent({ isHidden={isHiddenVault} showUserViews={showUserViews} chartVariant={expandedChartVariant} + apyDisplayVariant={apyDisplayVariant} + showBoostDetails={showBoostDetails} + expandedApyTooltip={isYvUsd ? yvUsdApyTooltip : undefined} /> ) : null} diff --git a/src/components/pages/vaults/components/list/VaultsListRowExpandedContent.tsx b/src/components/pages/vaults/components/list/VaultsListRowExpandedContent.tsx index 03fa97e28..7b747d615 100644 --- a/src/components/pages/vaults/components/list/VaultsListRowExpandedContent.tsx +++ b/src/components/pages/vaults/components/list/VaultsListRowExpandedContent.tsx @@ -1,15 +1,18 @@ import { usePlausible } from '@hooks/usePlausible' -import { VaultAboutSection } from '@pages/vaults/components/detail/VaultAboutSection' +import { type TVaultAddressItem, VaultAboutSection } from '@pages/vaults/components/detail/VaultAboutSection' import { type TVaultChartTab, type TVaultChartTimeframe, VaultChartsSection } from '@pages/vaults/components/detail/VaultChartsSection' import { YvUsdChartsSection } from '@pages/vaults/components/detail/YvUsdChartsSection' +import { resolveForwardApyDisplayConfig } from '@pages/vaults/components/table/apyDisplayConfig' +import type { TVaultForwardAPYVariant } from '@pages/vaults/components/table/VaultForwardAPY' import { getVaultAddress, getVaultChainID, getVaultName, + getVaultStaking, getVaultStrategies, getVaultSymbol, getVaultToken, @@ -18,8 +21,9 @@ import { type TKongVaultInput, type TKongVaultStrategy } from '@pages/vaults/domain/kongVaultSelectors' +import { useVaultApyData } from '@pages/vaults/hooks/useVaultApyData' import { useVaultSnapshot } from '@pages/vaults/hooks/useVaultSnapshot' -import { isYvUsdAddress } from '@pages/vaults/utils/yvUsd' +import { isYvUsdAddress, YVUSD_LOCKED_ADDRESS, YVUSD_UNLOCKED_ADDRESS } from '@pages/vaults/utils/yvUsd' import { AllocationChart, DARK_MODE_COLORS, @@ -32,8 +36,8 @@ import { useYearnTokenPrice } from '@shared/hooks/useYearnTokenPrice' import { formatCounterValue, toAddress, toBigInt, toNormalizedBN } from '@shared/utils' import { PLAUSIBLE_EVENTS } from '@shared/utils/plausible' import type { TKongVaultSnapshot } from '@shared/utils/schemas/kongVaultSnapshotSchema' -import type { MouseEvent, ReactElement } from 'react' -import { useMemo } from 'react' +import type { MouseEvent, ReactElement, ReactNode } from 'react' +import { cloneElement, isValidElement, useMemo } from 'react' import { type TVaultsExpandedView, VaultsExpandedSelector } from './VaultsExpandedSelector' const EXPANDED_VIEW_TO_CHART_TAB: Record< @@ -47,7 +51,6 @@ const EXPANDED_VIEW_TO_CHART_TAB: Record< type TExpandedChartView = keyof typeof EXPANDED_VIEW_TO_CHART_TAB type TMergedStrategy = TKongVaultStrategy & { name: string } - function isExpandedChartView(view: TVaultsExpandedView): view is TExpandedChartView { return view in EXPANDED_VIEW_TO_CHART_TAB } @@ -62,6 +65,9 @@ type TVaultsListRowExpandedContentProps = { isHidden?: boolean showUserViews?: boolean chartVariant?: 'default' | 'portfolio-user-tvl-overlay' + apyDisplayVariant?: TVaultForwardAPYVariant + showBoostDetails?: boolean + expandedApyTooltip?: ReactNode } export default function VaultsListRowExpandedContent({ @@ -73,17 +79,47 @@ export default function VaultsListRowExpandedContent({ showHiddenTag = false, isHidden, showUserViews = false, - chartVariant = 'default' + chartVariant = 'default', + apyDisplayVariant = 'default', + showBoostDetails = true, + expandedApyTooltip }: TVaultsListRowExpandedContentProps): ReactElement { const trackEvent = usePlausible() const chartTimeframe: TVaultChartTimeframe = '1y' const chainID = getVaultChainID(currentVault) const vaultAddress = getVaultAddress(currentVault) - const isYvUsd = isYvUsdAddress(vaultAddress) const { data: snapshotVault } = useVaultSnapshot({ chainId: chainID, address: vaultAddress }) + const isYvUsd = isYvUsdAddress(vaultAddress) + const token = getVaultToken(currentVault) + const staking = getVaultStaking(currentVault, snapshotVault) + const vaultAddresses = useMemo((): TVaultAddressItem[] => { + if (isYvUsd) { + const addresses: TVaultAddressItem[] = [ + { address: YVUSD_UNLOCKED_ADDRESS, label: 'Unlocked Vault Contract Address' }, + { address: YVUSD_LOCKED_ADDRESS, label: 'Locked Vault Contract Address' } + ] + if (token.address) { + addresses.push({ address: toAddress(token.address), label: 'Token Contract Address' }) + } + if (staking.available && staking.address) { + addresses.push({ address: toAddress(staking.address), label: 'Staking Contract Address' }) + } + return addresses + } + + const addresses: TVaultAddressItem[] = [{ address: toAddress(vaultAddress), label: 'Vault Contract Address' }] + if (token.address) { + addresses.push({ address: token.address, label: 'Token Contract Address' }) + } + if (staking.available && staking.address) { + addresses.push({ address: toAddress(staking.address), label: 'Staking Contract Address' }) + } + return addresses + }, [isYvUsd, staking.available, staking.address, token.address, vaultAddress]) + const vaultApyData = useVaultApyData(currentVault) const snapshotMergedVault = useMemo(() => getVaultView(currentVault, snapshotVault), [currentVault, snapshotVault]) const chartTab = isExpandedChartView(expandedView) ? EXPANDED_VIEW_TO_CHART_TAB[expandedView] : undefined const yvUsdChartTab = @@ -100,6 +136,30 @@ export default function VaultsListRowExpandedContent({ ], [shouldUsePortfolioUserTvlOverlay] ) + const { displayConfig } = useMemo( + () => + resolveForwardApyDisplayConfig({ + currentVault, + data: vaultApyData, + displayVariant: apyDisplayVariant, + showSubline: false, + showSublineTooltip: true, + showBoostDetails, + canOpenModal: false + }), + [currentVault, vaultApyData, apyDisplayVariant, showBoostDetails] + ) + const apyTooltipContent = isYvUsd ? expandedApyTooltip : displayConfig.tooltip?.content + const plainApyTooltipContent = useMemo(() => { + if (!isValidElement<{ className?: string }>(apyTooltipContent)) { + return apyTooltipContent + } + const tooltipContent = apyTooltipContent as ReactElement<{ className?: string }> + const className = tooltipContent.props.className + return cloneElement(tooltipContent, { + className: `${className ?? ''} bg-transparent border-0 shadow-none` + }) + }, [apyTooltipContent]) const handleGoToVault = (event: MouseEvent): void => { event.stopPropagation() @@ -120,9 +180,11 @@ export default function VaultsListRowExpandedContent({
diff --git a/src/components/pages/vaults/components/widget/deposit/DepositDetails.test.tsx b/src/components/pages/vaults/components/widget/deposit/DepositDetails.test.tsx index 1c4412444..11316137a 100644 --- a/src/components/pages/vaults/components/widget/deposit/DepositDetails.test.tsx +++ b/src/components/pages/vaults/components/widget/deposit/DepositDetails.test.tsx @@ -43,7 +43,82 @@ describe('DepositDetails', () => { expect(html).toContain('| 9.00') expect(html).toContain('Est. / Worst price impact') expect(html).toContain('0.00%') - expect(html).toContain('10.00%') + expect(html).toContain('-10.00%') expect(html).toContain('text-red-500') }) + + it('uses deposit-style copy for routed ENSO deposits without swaps', () => { + const html = renderToStaticMarkup( + undefined} + onShowVaultShareValueModal={() => undefined} + estimatedAnnualReturn={1} + onShowAnnualReturnModal={() => undefined} + /> + ) + + expect(html).toContain('You Will Deposit') + expect(html).not.toContain('For expected / at least') + expect(html).not.toContain('Est. / Worst price impact') + }) + + it('shows positive slippage for favorable swap quotes', () => { + const html = renderToStaticMarkup( + undefined} + onShowVaultShareValueModal={() => undefined} + estimatedAnnualReturn={1} + onShowAnnualReturnModal={() => undefined} + /> + ) + + expect(html).toContain('+5.00%') + expect(html).toContain('+2.00%') + }) }) diff --git a/src/components/pages/vaults/components/widget/deposit/DepositDetails.tsx b/src/components/pages/vaults/components/widget/deposit/DepositDetails.tsx index 841ec704b..b17ce6ce1 100644 --- a/src/components/pages/vaults/components/widget/deposit/DepositDetails.tsx +++ b/src/components/pages/vaults/components/widget/deposit/DepositDetails.tsx @@ -78,6 +78,13 @@ export const DepositDetails: FC = ({ }) => { const isStake = routeType === 'DIRECT_STAKE' const sharesLabel = willReceiveStakedShares ? 'Staked shares' : (vaultSharesLabel ?? 'Vault shares') + const formatSignedSlippage = (percentage: number) => { + if (percentage === 0) { + return '0.00%' + } + + return `${percentage < 0 ? '+' : '-'}${Math.abs(percentage).toFixed(2)}%` + } // Determine action verb based on route type const getActionVerb = () => { @@ -188,9 +195,9 @@ export const DepositDetails: FC = ({ ) : ( <> - {`${expectedPriceImpactPercentage.toFixed(2)}%`} + {formatSignedSlippage(expectedPriceImpactPercentage)} {' | '} - {`${priceImpactPercentage.toFixed(2)}%`} + {formatSignedSlippage(priceImpactPercentage)} )}

diff --git a/src/components/pages/vaults/components/widget/deposit/index.tsx b/src/components/pages/vaults/components/widget/deposit/index.tsx index c07b8e4f7..69295710b 100644 --- a/src/components/pages/vaults/components/widget/deposit/index.tsx +++ b/src/components/pages/vaults/components/widget/deposit/index.tsx @@ -64,6 +64,8 @@ interface Props { collapseDetails?: boolean detailsContent?: ReactNode contentBelowInput?: ReactNode + contentAboveButton?: ReactNode + actionLabelOverride?: string vaultSharesLabel?: string hideDetails?: boolean hideActionButton?: boolean @@ -96,17 +98,23 @@ function getDepositActionCopy(routeType: DepositRouteType): DepositActionCopy { } } -function getDepositButtonLabel(isLoadingRoute: boolean, needsApproval: boolean, routeType: DepositRouteType): string { +function getDepositButtonLabel( + isLoadingRoute: boolean, + needsApproval: boolean, + routeType: DepositRouteType, + actionLabelOverride?: string +): string { if (isLoadingRoute) { return 'Fetching quote' } const { actionLabel } = getDepositActionCopy(routeType) + const resolvedActionLabel = actionLabelOverride ?? actionLabel if (needsApproval) { - return `Approve & ${actionLabel}` + return `Approve & ${resolvedActionLabel}` } - return actionLabel + return resolvedActionLabel } function getTokenLogoURI(token: unknown): string | undefined { @@ -139,6 +147,8 @@ export function WidgetDeposit({ collapseDetails, detailsContent, contentBelowInput, + contentAboveButton, + actionLabelOverride, vaultSharesLabel, hideDetails = false, hideActionButton = false, @@ -447,16 +457,17 @@ export function WidgetDeposit({ assetTokenPrice ] ) + const ensoRouteHasSwap = routeType === 'ENSO' && Boolean(activeFlow.periphery.routeHasSwap) const desiredEnsoQuoteSlippage = useMemo( () => - routeType === 'ENSO' + routeType === 'ENSO' && activeFlow.periphery.routeHasSwap !== false ? calculateRemainingEnsoSlippagePercentage({ userTolerancePercentage: zapSlippage, quoteImpactPercentage: depositValueInfo.priceImpactPercentage }) - : zapSlippage, - [depositValueInfo.priceImpactPercentage, routeType, zapSlippage] + : 0, + [activeFlow.periphery.routeHasSwap, depositValueInfo.priceImpactPercentage, routeType, zapSlippage] ) useEffect(() => { @@ -560,14 +571,23 @@ export function WidgetDeposit({ // Calculate total price impact for warning and blocking. const priceImpactInfo = useMemo(() => { + if (!ensoRouteHasSwap) { + return { + percentage: 0, + isAboveTolerance: false, + isBlocking: false + } + } + return { percentage: depositValueInfo.worstCasePriceImpactPercentage, isAboveTolerance: depositValueInfo.worstCasePriceImpactPercentage > zapSlippage, isBlocking: depositValueInfo.worstCasePriceImpactPercentage >= ZAP_SLIPPAGE_HARD_CAP } - }, [depositValueInfo.worstCasePriceImpactPercentage, zapSlippage]) + }, [depositValueInfo.worstCasePriceImpactPercentage, ensoRouteHasSwap, zapSlippage]) const unpricedEnsoDepositError = routeType === 'ENSO' && + ensoRouteHasSwap && depositValueInfo.hasIncompleteUsdValuation && depositAmount.debouncedBn > 0n && !depositAmount.isDebouncing && @@ -770,16 +790,23 @@ export function WidgetDeposit({ } : undefined const displayedExpectedVaultShares = - routeType === 'ENSO' ? activeFlow.periphery.minExpectedOut : activeFlow.periphery.expectedOut + routeType === 'ENSO' && ensoRouteHasSwap ? activeFlow.periphery.minExpectedOut : activeFlow.periphery.expectedOut const displayedVaultShareValueInAsset = - routeType === 'ENSO' ? depositValueInfo.minVaultShareValueInAsset : depositValueInfo.vaultShareValueInAsset + routeType === 'ENSO' && ensoRouteHasSwap + ? depositValueInfo.minVaultShareValueInAsset + : depositValueInfo.vaultShareValueInAsset const displayedVaultShareValueUsdRaw = - routeType === 'ENSO' ? depositValueInfo.minVaultShareValueUsdRaw : depositValueInfo.vaultShareValueUsdRaw + routeType === 'ENSO' && ensoRouteHasSwap + ? depositValueInfo.minVaultShareValueUsdRaw + : depositValueInfo.vaultShareValueUsdRaw const displayedPriceImpactPercentage = - routeType === 'ENSO' ? depositValueInfo.worstCasePriceImpactPercentage : depositValueInfo.priceImpactPercentage + routeType === 'ENSO' && ensoRouteHasSwap + ? depositValueInfo.worstCasePriceImpactPercentage + : depositValueInfo.priceImpactPercentage const displayedShouldHighlightPriceImpact = - routeType === 'ENSO' && (priceImpactInfo.isAboveTolerance || priceImpactInfo.isBlocking) - const displayedConvertedVaultShares = routeType === 'ENSO' ? normalizedMinExpectedOut : normalizedExpectedOut + routeType === 'ENSO' && ensoRouteHasSwap && (priceImpactInfo.isAboveTolerance || priceImpactInfo.isBlocking) + const displayedConvertedVaultShares = + routeType === 'ENSO' && ensoRouteHasSwap ? normalizedMinExpectedOut : normalizedExpectedOut const shouldShowShareConversion = willReceiveStakedShares && displayedExpectedVaultShares !== displayedConvertedVaultShares @@ -792,7 +819,7 @@ export function WidgetDeposit({ inputTokenDecimals={inputToken?.decimals ?? 18} inputTokenUsdPrice={inputTokenPrice} routeType={routeType} - isSwap={selectedToken !== assetAddress} + isSwap={ensoRouteHasSwap} isLoadingQuote={isLoadingQuote} isQuoteStale={depositAmount.isDebouncing || depositAmount.bn !== depositAmount.debouncedBn} expectedOutInAsset={expectedOutInAsset} @@ -838,7 +865,7 @@ export function WidgetDeposit({ const showActionRow = !hideActionButton || !!onOpenSettings const showSettingsButton = !!account && !!onOpenSettings - const depositButtonLabel = getDepositButtonLabel(isLoadingQuote, needsApproval, routeType) + const depositButtonLabel = getDepositButtonLabel(isLoadingQuote, needsApproval, routeType, actionLabelOverride) const isDepositButtonDisabled = !!effectiveDepositError || depositAmount.bn === 0n || @@ -846,12 +873,12 @@ export function WidgetDeposit({ depositAmount.isDebouncing || (!activeFlow.periphery.isAllowanceSufficient && !activeFlow.periphery.prepareApproveEnabled) || (activeFlow.periphery.isAllowanceSufficient && !activeFlow.periphery.prepareDepositEnabled) || - priceImpactInfo.isBlocking || - priceImpactInfo.isAboveTolerance + (ensoRouteHasSwap && (priceImpactInfo.isBlocking || priceImpactInfo.isAboveTolerance)) const actionRow = showActionRow ? (
{priceImpactWarning} + {contentAboveButton}
{hideActionButton ? null : !account ? ( diff --git a/src/components/pages/vaults/components/widget/deposit/useDepositFlow.ts b/src/components/pages/vaults/components/widget/deposit/useDepositFlow.ts index 6e88fd64c..9eda426f7 100644 --- a/src/components/pages/vaults/components/widget/deposit/useDepositFlow.ts +++ b/src/components/pages/vaults/components/widget/deposit/useDepositFlow.ts @@ -53,6 +53,7 @@ export interface DepositFlowResult { minExpectedOut: bigint normalizedExpectedOut: bigint normalizedMinExpectedOut: bigint + routeHasSwap?: boolean isLoadingRoute: boolean isLoadingExpectedOutNormalization: boolean isCrossChain: boolean diff --git a/src/components/pages/vaults/components/widget/deposit/valuation.test.ts b/src/components/pages/vaults/components/widget/deposit/valuation.test.ts index 9b9528bbc..19be19f1c 100644 --- a/src/components/pages/vaults/components/widget/deposit/valuation.test.ts +++ b/src/components/pages/vaults/components/widget/deposit/valuation.test.ts @@ -145,6 +145,23 @@ describe('calculateDepositValueInfo', () => { expect(valueInfo.worstCasePriceImpactPercentage).toBe(1) }) + it('preserves favorable execution as negative price impact for UI display', () => { + const valueInfo = calculateDepositValueInfo({ + depositAmountBn: 100n * ONE_ETHER, + inputTokenDecimals: 18, + inputTokenUsdPrice: 1, + normalizedVaultShares: 105n * ONE_ETHER, + normalizedMinVaultShares: 102n * ONE_ETHER, + vaultDecimals: 18, + pricePerShare: ONE_ETHER, + assetTokenDecimals: 18, + assetUsdPrice: 1 + }) + + expect(valueInfo.priceImpactPercentage).toBe(-5) + expect(valueInfo.worstCasePriceImpactPercentage).toBe(-2) + }) + it('flags incomplete USD valuation when a supported zap quote has no token price', () => { const valueInfo = calculateDepositValueInfo({ depositAmountBn: 100n * ONE_ETHER, diff --git a/src/components/pages/vaults/components/widget/deposit/valuation.ts b/src/components/pages/vaults/components/widget/deposit/valuation.ts index c79bec7ff..72e4abf58 100644 --- a/src/components/pages/vaults/components/widget/deposit/valuation.ts +++ b/src/components/pages/vaults/components/widget/deposit/valuation.ts @@ -17,9 +17,7 @@ function calculatePriceImpactPercentage({ inputUsdValue: number outputUsdValue: number }): number { - return inputUsdValue > 0 && outputUsdValue > 0 - ? Math.max(0, ((inputUsdValue - outputUsdValue) / inputUsdValue) * 100) - : 0 + return inputUsdValue > 0 && outputUsdValue > 0 ? ((inputUsdValue - outputUsdValue) / inputUsdValue) * 100 : 0 } export function resolveValuationShareCount({ diff --git a/src/components/pages/vaults/components/widget/withdraw/WithdrawDetails.test.tsx b/src/components/pages/vaults/components/widget/withdraw/WithdrawDetails.test.tsx index 32b15232b..3721321da 100644 --- a/src/components/pages/vaults/components/widget/withdraw/WithdrawDetails.test.tsx +++ b/src/components/pages/vaults/components/widget/withdraw/WithdrawDetails.test.tsx @@ -26,7 +26,7 @@ describe('WithdrawDetails', () => { expectedPriceImpactPercentage={0} priceImpactPercentage={0} shouldHighlightPriceImpact={false} - routeType="ENSO" + hasSwap onShowDetailsModal={() => undefined} /> ) @@ -55,14 +55,75 @@ describe('WithdrawDetails', () => { expectedPriceImpactPercentage={0} priceImpactPercentage={10} shouldHighlightPriceImpact - routeType="ENSO" + hasSwap onShowDetailsModal={() => undefined} /> ) expect(html).toContain('Est. / Worst price impact') expect(html).toContain('0.00%') - expect(html).toContain('10.00%') + expect(html).toContain('-10.00%') expect(html).toContain('text-red-500') }) + + it('uses receive copy for routed ENSO withdrawals without swaps', () => { + const html = renderToStaticMarkup( + undefined} + /> + ) + + expect(html).toContain('You will receive') + expect(html).not.toContain('You will receive at least') + expect(html).not.toContain('Est. / Worst price impact') + }) + + it('shows positive slippage for favorable zap withdrawals', () => { + const html = renderToStaticMarkup( + undefined} + /> + ) + + expect(html).toContain('+6.00%') + expect(html).toContain('+3.00%') + }) }) diff --git a/src/components/pages/vaults/components/widget/withdraw/WithdrawDetails.tsx b/src/components/pages/vaults/components/widget/withdraw/WithdrawDetails.tsx index 85ae2c8d5..9bcfcb1ec 100644 --- a/src/components/pages/vaults/components/widget/withdraw/WithdrawDetails.tsx +++ b/src/components/pages/vaults/components/widget/withdraw/WithdrawDetails.tsx @@ -2,7 +2,6 @@ import { formatCounterValue } from '@shared/utils/format' import type { ReactElement } from 'react' import { formatUnits } from 'viem' import { formatWidgetAllowance, formatWidgetValue } from '../shared/valueDisplay' -import type { WithdrawRouteType } from './types' interface WithdrawDetailsProps { // Action label @@ -28,8 +27,7 @@ interface WithdrawDetailsProps { expectedPriceImpactPercentage: number priceImpactPercentage: number shouldHighlightPriceImpact: boolean - // Route type for "at least" text - routeType: WithdrawRouteType + hasSwap: boolean // Modal trigger onShowDetailsModal: () => void // Approval info (for zap withdrawals) @@ -64,7 +62,7 @@ export function WithdrawDetails({ expectedPriceImpactPercentage, priceImpactPercentage, shouldHighlightPriceImpact, - routeType, + hasSwap, onShowDetailsModal, allowance, allowanceTokenDecimals, @@ -75,11 +73,18 @@ export function WithdrawDetails({ }: WithdrawDetailsProps): ReactElement { const allowanceDisplay = formatWidgetAllowance(allowance, allowanceTokenDecimals) const approvalLabel = getApprovalLabel(approvalSpenderName) + const formatSignedSlippage = (percentage: number) => { + if (percentage === 0) { + return '0.00%' + } + + return `${percentage < 0 ? '+' : '-'}${Math.abs(percentage).toFixed(2)}%` + } const withdrawUsdDisplay = formatCounterValue(formatUnits(withdrawAmountBn, assetDecimals), assetUsdPrice) const expectedOutUsdDisplay = formatCounterValue(formatUnits(expectedOut, outputDecimals), outputUsdPrice) const shouldShowWithdrawUsdBadge = showSwapRow && assetUsdPrice > 0 && withdrawAmountBn > 0n - const shouldShowExpectedOutUsdBadge = routeType === 'ENSO' && outputUsdPrice > 0 && expectedOut > 0n - const shouldShowWorstCasePriceImpact = showSwapRow && routeType === 'ENSO' + const shouldShowExpectedOutUsdBadge = hasSwap && outputUsdPrice > 0 && expectedOut > 0n + const shouldShowWorstCasePriceImpact = showSwapRow && hasSwap const shouldUseHighlight = !isQuoteStale && !isLoadingQuote && shouldHighlightPriceImpact return (
@@ -121,7 +126,7 @@ export function WithdrawDetails({ {/* You will receive */}
-

You will receive{routeType === 'ENSO' ? ' at least' : ''}

+

You will receive{hasSwap ? ' at least' : ''}

{isLoadingQuote ? ( @@ -155,9 +160,9 @@ export function WithdrawDetails({ ) : ( <> - {`${expectedPriceImpactPercentage.toFixed(2)}%`} + {formatSignedSlippage(expectedPriceImpactPercentage)} {' | '} - {`${priceImpactPercentage.toFixed(2)}%`} + {formatSignedSlippage(priceImpactPercentage)} )}

diff --git a/src/components/pages/vaults/components/widget/withdraw/WithdrawDetailsOverlay.tsx b/src/components/pages/vaults/components/widget/withdraw/WithdrawDetailsOverlay.tsx index 4f6c12396..a62d55519 100644 --- a/src/components/pages/vaults/components/widget/withdraw/WithdrawDetailsOverlay.tsx +++ b/src/components/pages/vaults/components/widget/withdraw/WithdrawDetailsOverlay.tsx @@ -18,6 +18,7 @@ interface WithdrawDetailsOverlayProps { withdrawalSource: WithdrawalSource routeType: WithdrawRouteType isZap: boolean + hasSwap: boolean isLoadingQuote: boolean } @@ -32,7 +33,8 @@ export const WithdrawDetailsOverlay: FC = ({ hasInputValue, withdrawalSource, routeType, - isZap + isZap, + hasSwap }) => { const isFromStaking = withdrawalSource === 'staking' const isUnstake = routeType === 'DIRECT_UNSTAKE' @@ -66,7 +68,7 @@ export const WithdrawDetailsOverlay: FC = ({ return {outputTokenSymbol} } - const receiveLabel = isZap && hasInputValue ? "You'll receive at least:" : "You'll receive:" + const receiveLabel = isZap && hasSwap && hasInputValue ? "You'll receive at least:" : "You'll receive:" return ( @@ -78,8 +80,17 @@ export const WithdrawDetailsOverlay: FC = ({ {isZap ? ( <> Your {sourceTokenSymbol} shares will be - redeemed for {vaultAssetSymbol}, then swapped - to {outputTokenSymbol}. + redeemed for {vaultAssetSymbol} + {hasSwap ? ( + <> + , then swapped to {outputTokenSymbol}. + + ) : ( + <> + . The route then forwards them to{' '} + {outputTokenSymbol} without a market swap. + + )} ) : isUnstake ? ( <> @@ -112,10 +123,17 @@ export const WithdrawDetailsOverlay: FC = ({

How it works

{isZap ? ( - <> - Your vault shares are redeemed for the underlying asset, which is then swapped to your desired token - using Enso. The final amount may vary slightly due to market conditions. - + hasSwap ? ( + <> + Your vault shares are redeemed for the underlying asset, which is then swapped to your desired token + using Enso. The final amount may vary slightly due to market conditions. + + ) : ( + <> + Your vault shares are redeemed for the underlying asset, and Enso batches the required protocol calls + to route the proceeds to your selected token without a market swap. + + ) ) : isUnstake ? ( <> Unstaking converts your staked position back to vault shares. Your vault shares continue to earn yield diff --git a/src/components/pages/vaults/components/widget/withdraw/index.tsx b/src/components/pages/vaults/components/widget/withdraw/index.tsx index e3b5849ea..996bb4540 100644 --- a/src/components/pages/vaults/components/widget/withdraw/index.tsx +++ b/src/components/pages/vaults/components/widget/withdraw/index.tsx @@ -389,9 +389,10 @@ export function WidgetWithdraw({ ]) const isCrossChain = destinationChainId !== chainId + const ensoRouteHasSwap = routeType === 'ENSO' && Boolean(activeFlow.periphery.routeHasSwap) const effectiveExpectedOut = expectedOutOverride ?? activeFlow.periphery.expectedOut const effectiveMinExpectedOut = expectedOutOverride ?? activeFlow.periphery.minExpectedOut - const displayedExpectedOut = routeType === 'ENSO' ? effectiveMinExpectedOut : effectiveExpectedOut + const displayedExpectedOut = routeType === 'ENSO' && ensoRouteHasSwap ? effectiveMinExpectedOut : effectiveExpectedOut const { approveNotificationParams, unstakeNotificationParams, withdrawNotificationParams } = useWithdrawNotifications( { vault, @@ -547,15 +548,21 @@ export function WidgetWithdraw({ const desiredEnsoQuoteSlippage = useMemo( () => - routeType === 'ENSO' + routeType === 'ENSO' && activeFlow.periphery.routeHasSwap !== false ? withdrawValueInfo.hasIncompleteUsdValuation ? 0 : calculateRemainingEnsoSlippagePercentage({ userTolerancePercentage: zapSlippage, quoteImpactPercentage: withdrawValueInfo.priceImpactPercentage }) - : zapSlippage, - [routeType, withdrawValueInfo.hasIncompleteUsdValuation, withdrawValueInfo.priceImpactPercentage, zapSlippage] + : 0, + [ + activeFlow.periphery.routeHasSwap, + routeType, + withdrawValueInfo.hasIncompleteUsdValuation, + withdrawValueInfo.priceImpactPercentage, + zapSlippage + ] ) useEffect(() => { @@ -652,14 +659,23 @@ export function WidgetWithdraw({ // Calculate total price impact for warning and blocking. const priceImpactInfo = useMemo(() => { + if (!ensoRouteHasSwap) { + return { + percentage: 0, + isAboveTolerance: false, + isBlocking: false + } + } + return { percentage: withdrawValueInfo.worstCasePriceImpactPercentage, isAboveTolerance: withdrawValueInfo.worstCasePriceImpactPercentage > zapSlippage, isBlocking: withdrawValueInfo.worstCasePriceImpactPercentage >= ZAP_SLIPPAGE_HARD_CAP } - }, [withdrawValueInfo.worstCasePriceImpactPercentage, zapSlippage]) + }, [ensoRouteHasSwap, withdrawValueInfo.worstCasePriceImpactPercentage, zapSlippage]) const unpricedEnsoWithdrawError = routeType === 'ENSO' && + ensoRouteHasSwap && withdrawValueInfo.hasIncompleteUsdValuation && withdrawAmount.debouncedBn > 0n && !withdrawAmount.isDebouncing && @@ -672,9 +688,11 @@ export function WidgetWithdraw({ const shouldShowZapUi = !isBaseWithdrawToken const canShowAssetTokenSelector = canOpenTokenSelector && !shouldShowZapUi const displayedPriceImpactPercentage = - routeType === 'ENSO' ? withdrawValueInfo.worstCasePriceImpactPercentage : withdrawValueInfo.priceImpactPercentage + routeType === 'ENSO' && ensoRouteHasSwap + ? withdrawValueInfo.worstCasePriceImpactPercentage + : withdrawValueInfo.priceImpactPercentage const shouldHighlightDisplayedPriceImpact = - routeType === 'ENSO' && (priceImpactInfo.isAboveTolerance || priceImpactInfo.isBlocking) + routeType === 'ENSO' && ensoRouteHasSwap && (priceImpactInfo.isAboveTolerance || priceImpactInfo.isBlocking) const zapToken = useMemo(() => { if (!shouldShowZapUi) return undefined @@ -907,7 +925,7 @@ export function WidgetWithdraw({ expectedOut={displayedExpectedOut} outputDecimals={outputToken?.decimals ?? 18} outputSymbol={outputToken?.symbol} - showSwapRow={withdrawToken !== resolvedDisplayAssetAddress && !isUnstake} + showSwapRow={ensoRouteHasSwap && !isUnstake} withdrawAmountSimple={ withdrawAmount.bn > 0n ? formatWidgetValue(withdrawAmount.bn, assetToken?.decimals ?? 18) : '0' } @@ -919,7 +937,7 @@ export function WidgetWithdraw({ expectedPriceImpactPercentage={withdrawValueInfo.priceImpactPercentage} priceImpactPercentage={displayedPriceImpactPercentage} shouldHighlightPriceImpact={shouldHighlightDisplayedPriceImpact} - routeType={routeType} + hasSwap={ensoRouteHasSwap} onShowDetailsModal={() => setShowWithdrawDetailsModal(true)} allowance={approvalState.hasApprovalStep ? activeFlow.periphery.allowance : undefined} allowanceTokenDecimals={approvalState.hasApprovalStep ? approvalState.tokenDecimals : undefined} @@ -960,8 +978,7 @@ export function WidgetWithdraw({ prepareApproveEnabled: Boolean(activeFlow.periphery.prepareApproveEnabled), prepareWithdrawEnabled: Boolean(activeFlow.periphery.prepareWithdrawEnabled) }) || - priceImpactInfo.isBlocking || - priceImpactInfo.isAboveTolerance + (ensoRouteHasSwap && (priceImpactInfo.isBlocking || priceImpactInfo.isAboveTolerance)) const actionRow = (

@@ -1135,6 +1152,7 @@ export function WidgetWithdraw({ withdrawalSource={withdrawalSource} routeType={routeType} isZap={routeType === 'ENSO' && shouldShowZapUi} + hasSwap={ensoRouteHasSwap} isLoadingQuote={isFetchingQuote} /> diff --git a/src/components/pages/vaults/components/widget/withdraw/valuation.test.ts b/src/components/pages/vaults/components/widget/withdraw/valuation.test.ts index 3acdcf46e..9c396cc3b 100644 --- a/src/components/pages/vaults/components/widget/withdraw/valuation.test.ts +++ b/src/components/pages/vaults/components/widget/withdraw/valuation.test.ts @@ -47,4 +47,19 @@ describe('calculateWithdrawValueInfo', () => { expect(valueInfo.priceImpactPercentage).toBe(0) expect(valueInfo.worstCasePriceImpactPercentage).toBe(1) }) + + it('preserves favorable execution as negative price impact for UI display', () => { + const valueInfo = calculateWithdrawValueInfo({ + withdrawAmountBn: 100n * ONE_ETHER, + assetTokenDecimals: 18, + assetUsdPrice: 1, + expectedOut: 105n * ONE_ETHER, + minExpectedOut: 102n * ONE_ETHER, + outputDecimals: 18, + outputUsdPrice: 1 + }) + + expect(valueInfo.priceImpactPercentage).toBe(-5) + expect(valueInfo.worstCasePriceImpactPercentage).toBe(-2) + }) }) diff --git a/src/components/pages/vaults/components/widget/withdraw/valuation.ts b/src/components/pages/vaults/components/widget/withdraw/valuation.ts index d599811d3..1797d1b97 100644 --- a/src/components/pages/vaults/components/widget/withdraw/valuation.ts +++ b/src/components/pages/vaults/components/widget/withdraw/valuation.ts @@ -26,9 +26,7 @@ function calculatePriceImpactPercentage({ return 0 } - return outputUsdValueRaw > 0 - ? Math.max(0, ((withdrawUsdValueRaw - outputUsdValueRaw) / withdrawUsdValueRaw) * 100) - : 0 + return outputUsdValueRaw > 0 ? ((withdrawUsdValueRaw - outputUsdValueRaw) / withdrawUsdValueRaw) * 100 : 0 } export function calculateWithdrawValueInfo({ diff --git a/src/components/pages/vaults/components/widget/yvBTC/YvBtcWidget.tsx b/src/components/pages/vaults/components/widget/yvBTC/YvBtcWidget.tsx new file mode 100644 index 000000000..fbaaf94e1 --- /dev/null +++ b/src/components/pages/vaults/components/widget/yvBTC/YvBtcWidget.tsx @@ -0,0 +1,220 @@ +import { useVaultUserData } from '@pages/vaults/hooks/useVaultUserData' +import { useYvBtcVaults } from '@pages/vaults/hooks/useYvBtcVaults' +import { WidgetActionType as ActionType } from '@pages/vaults/types' +import type { TYvUsdVariant } from '@pages/vaults/utils/yvUsd' +import { Button } from '@shared/components/Button' +import { cl } from '@shared/utils' +import type { ReactElement, ReactNode } from 'react' +import { useState } from 'react' +import { useAccount } from 'wagmi' +import { WidgetDeposit } from '../deposit' +import { WidgetWithdraw } from '../withdraw' +import { YvUsdVariantToggle } from '../yvUSD/YvUsdVariantToggle' + +interface Props { + chainId: number + mode?: ActionType + onModeChange?: (mode: ActionType) => void + onOpenSettings?: () => void + isSettingsOpen?: boolean + showTabs?: boolean + collapseDetails?: boolean + handleSuccess?: () => void + onVariantChange?: (variant: TYvUsdVariant) => void +} + +export function YvBtcWidget({ + chainId, + mode: controlledMode, + onModeChange, + onOpenSettings, + isSettingsOpen, + showTabs = true, + collapseDetails, + handleSuccess, + onVariantChange +}: Props): ReactElement { + const [internalMode, setInternalMode] = useState(ActionType.Deposit) + const mode = controlledMode ?? internalMode + const setMode = onModeChange ?? setInternalMode + + return ( +
+
+ {showTabs ? ( +
+ {[ActionType.Deposit, ActionType.Withdraw].map((action) => ( + setMode(action)}> + {action === ActionType.Deposit ? 'Deposit' : 'Withdraw'} + + ))} +
+ ) : null} + +
+
+ ) +} + +function YvBtcWidgetBody({ + mode, + chainId, + handleSuccess, + onOpenSettings, + isSettingsOpen, + collapseDetails, + onVariantChange +}: { + mode: ActionType + chainId: number + handleSuccess?: () => void + onOpenSettings?: () => void + isSettingsOpen?: boolean + collapseDetails?: boolean + onVariantChange?: (variant: TYvUsdVariant) => void +}): ReactElement | null { + const { address: account } = useAccount() + const { unlockedVault, metrics, isLoading } = useYvBtcVaults() + const [variant, setVariant] = useState('unlocked') + const isLockedVariant = variant === 'locked' + const assetAddress = unlockedVault?.token.address + const unlockedUserData = useVaultUserData({ + vaultAddress: unlockedVault?.address, + assetAddress, + chainId, + account + }) + + const handleVariantChange = (nextVariant: TYvUsdVariant): void => { + setVariant(nextVariant) + onVariantChange?.(nextVariant) + } + + const headerActions = + + if (isLoading || !unlockedVault || !assetAddress) { + return ( +
+
+
+ ) + } + + if (isLockedVariant) { + return + } + + if (mode === ActionType.Deposit) { + return ( + } + actionLabelOverride={'Deposit to Unlocked'} + collapseDetails={Boolean(collapseDetails)} + vaultSharesLabel={'Unlocked Vault Shares'} + /> + ) + } + + if (mode === ActionType.Withdraw) { + return ( + } + collapseDetails={Boolean(collapseDetails)} + /> + ) + } + + return null +} + +function YvBtcUnlockedNotice({ mode }: { mode: ActionType }): ReactElement { + return ( +
+

+ {mode === ActionType.Deposit + ? 'Unlocked yvBTC deposits stay liquid. The locked yvBTC contract is not live yet.' + : 'Unlocked yvBTC withdrawals are available. Locked yvBTC withdrawals will be enabled once the locked contract is live.'} +

+
+ ) +} + +function YvBtcLockedPlaceholder({ mode, headerActions }: { mode: ActionType; headerActions: ReactNode }): ReactElement { + const actionLabel = mode === ActionType.Deposit ? 'Deposit to Locked' : 'Withdraw from Locked' + + return ( +
+
+
+

+ {mode === ActionType.Deposit ? 'Deposit' : 'Withdraw'} +

+

{'Select yvBTC variant'}

+
+ {headerActions} +
+
+

{'Locked yvBTC is not live yet.'}

+

+ {'This page is wired for the dual-vault launch, but the locked vault address is still a placeholder.'} +

+
+ +
+ ) +} + +type TabButtonProps = { + children: ReactNode + onClick: () => void + isActive: boolean +} + +function TabButton({ children, onClick, isActive }: TabButtonProps): ReactElement { + return ( + + ) +} diff --git a/src/components/pages/vaults/components/widget/yvUSD/YvUsdDeposit.tsx b/src/components/pages/vaults/components/widget/yvUSD/YvUsdDeposit.tsx index de7e39441..01ba48377 100644 --- a/src/components/pages/vaults/components/widget/yvUSD/YvUsdDeposit.tsx +++ b/src/components/pages/vaults/components/widget/yvUSD/YvUsdDeposit.tsx @@ -8,9 +8,6 @@ import { YVUSD_UNLOCKED_ADDRESS, YVUSD_WITHDRAW_WINDOW_DAYS } from '@pages/vaults/utils/yvUsd' -import { Button } from '@shared/components/Button' -import { IconLock } from '@shared/icons/IconLock' -import { IconLockOpen } from '@shared/icons/IconLockOpen' import type { TToken } from '@shared/types' import { toAddress, zeroNormalizedBN } from '@shared/utils' import type { ReactElement } from 'react' @@ -81,7 +78,7 @@ function getYvUsdAmountUnit(address: `0x${string}`, underlyingAssetAddress: `0x$ } function getDepositPrefill( - variant: TYvUsdVariant | null, + variant: TYvUsdVariant, unlockedAssetAddress: `0x${string}`, chainId: number, pendingPrefillAmount?: string @@ -103,25 +100,21 @@ function getDepositPrefill( return undefined } -function getYvUsdDepositSymbol(variant: TYvUsdVariant | null): string { +function getYvUsdDepositSymbol(variant: TYvUsdVariant): string { switch (variant) { case 'locked': return 'yvUSD (Locked)' case 'unlocked': return 'yvUSD (Unlocked)' - default: - return 'yvUSD' } } -function getVaultSharesLabel(variant: TYvUsdVariant | null): string | undefined { +function getVaultSharesLabel(variant: TYvUsdVariant): string { switch (variant) { case 'locked': return 'Locked Vault Shares' case 'unlocked': return 'Unlocked Vault Shares' - default: - return undefined } } @@ -136,7 +129,7 @@ export function YvUsdDeposit({ }: Props): ReactElement { const { address: account } = useAccount() const { unlockedVault, lockedVault, metrics, isLoading } = useYvUsdVaults() - const [variant, setVariant] = useState(null) + const [variant, setVariant] = useState('locked') const [draftDepositAmount, setDraftDepositAmount] = useState('') const [pendingPrefillAmount, setPendingPrefillAmount] = useState(undefined) const unlockedAssetAddress = toAddress(unlockedVault?.token.address ?? assetAddress) @@ -238,45 +231,16 @@ export function YvUsdDeposit({ } const depositPrefill = getDepositPrefill(variant, unlockedAssetAddress, chainId, pendingPrefillAmount) - const headerToggle = - variant === null ? undefined : + const headerToggle = - const depositTypeSection = variant ? ( -
-

+ const depositTypeSection = ( +

+

{variant === 'locked' ? `Locked deposits earn additional yield from unlocked positions. Your position will be locked with a ${YVUSD_LOCKED_COOLDOWN_DAYS}-day cooldown and a ${YVUSD_WITHDRAW_WINDOW_DAYS} day withdrawal window.` - : `Unlocked deposits stay liquid but earn less.`} + : `Unlocked deposits stay liquid but earn less than locked deposits.`}

- ) : ( -
-

{'You can lock your vault position to earn additional yield. Locking helps manage system liquidity.'}

-

{`Locks are subject to a ${YVUSD_LOCKED_COOLDOWN_DAYS}-day cooldown and a ${YVUSD_WITHDRAW_WINDOW_DAYS} day withdrawal window.`}

-

{'Please choose your deposit type'}

-
- - -
-
) return ( @@ -291,16 +255,17 @@ export function YvUsdDeposit({ vaultSymbol={getYvUsdDepositSymbol(variant)} vaultUserData={selectedVaultUserData} handleDepositSuccess={handleDepositSuccess} - onOpenSettings={variant ? onOpenSettings : undefined} - isSettingsOpen={variant ? isSettingsOpen : false} + onOpenSettings={onOpenSettings} + isSettingsOpen={isSettingsOpen} onAmountChange={setDraftDepositAmount} onTokenSelectionChange={setSelectedDepositTokenAddress} - hideDetails={!variant} - hideActionButton={!variant} + hideDetails={false} + hideActionButton={false} hideContainerBorder headerActions={headerToggle} - contentBelowInput={depositTypeSection} - collapseDetails={Boolean(collapseDetails && variant !== null)} + contentAboveButton={depositTypeSection} + actionLabelOverride={isLockedVariant ? 'Deposit to Locked' : 'Deposit to Unlocked'} + collapseDetails={Boolean(collapseDetails)} prefill={depositPrefill} onPrefillApplied={() => setPendingPrefillAmount(undefined)} tokenSelectorExtraTokens={lockedDepositExtraTokens} diff --git a/src/components/pages/vaults/components/widget/yvUSD/YvUsdVariantToggle.tsx b/src/components/pages/vaults/components/widget/yvUSD/YvUsdVariantToggle.tsx index d3b6e5015..d3a07a3a6 100644 --- a/src/components/pages/vaults/components/widget/yvUSD/YvUsdVariantToggle.tsx +++ b/src/components/pages/vaults/components/widget/yvUSD/YvUsdVariantToggle.tsx @@ -11,8 +11,8 @@ type Props = { } const VARIANT_OPTIONS: { id: TYvUsdVariant; label: string; icon: ReactElement }[] = [ - { id: 'unlocked', label: 'Unlocked', icon: }, - { id: 'locked', label: 'Locked', icon: } + { id: 'locked', label: 'Locked', icon: }, + { id: 'unlocked', label: 'Unlocked', icon: } ] export function YvUsdVariantToggle({ activeVariant, onChange }: Props): ReactElement { diff --git a/src/components/pages/vaults/components/widget/yvUSD/YvUsdWithdraw.tsx b/src/components/pages/vaults/components/widget/yvUSD/YvUsdWithdraw.tsx index cbff241ce..1975edaa5 100644 --- a/src/components/pages/vaults/components/widget/yvUSD/YvUsdWithdraw.tsx +++ b/src/components/pages/vaults/components/widget/yvUSD/YvUsdWithdraw.tsx @@ -1173,7 +1173,7 @@ export function YvUsdWithdraw({ } const withdrawTypeSection = isLockedVariant ? ( -
+

{'Locked withdrawal cooldown'}

@@ -1185,11 +1185,11 @@ export function YvUsdWithdraw({ {'more info'}
-

+

{`Cooldown: ${cooldownDurationLabel} | Withdrawal window: ${withdrawalWindowLabel}`}

-
{cooldownStatusContent}
+
{cooldownStatusContent}
{showLockedWithdrawAction ? (
+
)} - + {isAdvancedOpen && ( +
+
+ {'Show dust'} + +
+
+ )}
diff --git a/src/components/shared/contexts/useWallet.tsx b/src/components/shared/contexts/useWallet.tsx index 6eb56b0ca..80a0de2e6 100644 --- a/src/components/shared/contexts/useWallet.tsx +++ b/src/components/shared/contexts/useWallet.tsx @@ -1,3 +1,5 @@ +import { isPortfolioDustValueVisible } from '@pages/portfolio/hooks/portfolioVisibility' +import { useAppSettings } from '@pages/vaults/contexts/useAppSettings' import { getVaultAddress, getVaultAPR, @@ -107,6 +109,7 @@ export const WalletContextApp = memo(function WalletContextApp(props: { const { vaults, allVaults, isLoadingVaultList, getPrice } = useYearn() const { unlockedVault: yvUsdUnlockedVault, lockedVault: yvUsdLockedVault } = useYvUsdVaults() const { address: userAddress } = useWeb3() + const { shouldHideDust } = useAppSettings() const allTokens = useYearnTokens({ vaults: allVaults, @@ -274,6 +277,9 @@ export const WalletContextApp = memo(function WalletContextApp(props: { const sharePrice = normalizedAddress === YVUSD_UNLOCKED_ADDRESS ? yvUsdUnlockedSharePrice : yvUsdLockedSharePrice const tokenValue = tokenData.value || tokenData.balance.normalized * sharePrice + if (!isPortfolioDustValueVisible(tokenValue, shouldHideDust)) { + continue + } cumulatedValueInV3Vaults += tokenValue continue } @@ -298,6 +304,9 @@ export const WalletContextApp = memo(function WalletContextApp(props: { balanceNormalized: tokenData.balance.normalized, getPrice }) + if (!isPortfolioDustValueVisible(tokenValue, shouldHideDust)) { + continue + } const vaultVersion = getVaultVersion(vaultDetails) const isV3 = vaultVersion.startsWith('3') || vaultVersion.startsWith('~3') @@ -309,7 +318,7 @@ export const WalletContextApp = memo(function WalletContextApp(props: { } } return [cumulatedValueInV2Vaults, cumulatedValueInV3Vaults] - }, [allVaults, balances, getPrice, yvUsdLockedSharePrice, yvUsdUnlockedSharePrice]) + }, [allVaults, balances, getPrice, shouldHideDust, yvUsdLockedSharePrice, yvUsdUnlockedSharePrice]) /*************************************************************************** ** Setup and render the Context provider to use in the app. diff --git a/src/components/shared/hooks/useFetchYearnVaults.test.ts b/src/components/shared/hooks/useFetchYearnVaults.test.ts index 78d7c8fa4..06ddc8434 100644 --- a/src/components/shared/hooks/useFetchYearnVaults.test.ts +++ b/src/components/shared/hooks/useFetchYearnVaults.test.ts @@ -1,3 +1,4 @@ +import { YVBTC_UNLOCKED_ADDRESS } from '@pages/vaults/utils/yvBtc' import type { TKongVaultListItem } from '@shared/utils/schemas/kongVaultListSchema' import { describe, expect, it } from 'vitest' import { isCatalogYearnVault } from './useFetchYearnVaults' @@ -34,4 +35,10 @@ describe('isCatalogYearnVault', () => { it('excludes yearn vaults that Kong marks as not included', () => { expect(isCatalogYearnVault(makeVault({ origin: 'yearn', inclusion: { isYearn: false } as never }))).toBe(false) }) + + it('does not include yvBTC in the public catalog before launch without Yearn metadata', () => { + expect( + isCatalogYearnVault(makeVault({ address: YVBTC_UNLOCKED_ADDRESS, origin: null, inclusion: {} as never })) + ).toBe(false) + }) }) diff --git a/src/components/shared/hooks/useV2VaultFilter.ts b/src/components/shared/hooks/useV2VaultFilter.ts index d972a1dbc..79aff2a7d 100644 --- a/src/components/shared/hooks/useV2VaultFilter.ts +++ b/src/components/shared/hooks/useV2VaultFilter.ts @@ -13,6 +13,7 @@ import { } from '@pages/vaults/domain/kongVaultSelectors' import { getHoldingsAliasVaultAddress } from '@pages/vaults/domain/normalizeVault' import { DEFAULT_MIN_TVL } from '@pages/vaults/utils/constants' +import { getVaultFeeStructureKey } from '@pages/vaults/utils/vaultFees' import { deriveAssetCategory, deriveListKind, @@ -43,6 +44,7 @@ type TVaultIndexEntry = { kind: ReturnType category: string aggressiveness: TVaultAggressiveness | null + feeStructureKey: string isHidden: boolean isActive: boolean isMigratable: boolean @@ -74,6 +76,7 @@ export function useV2VaultFilter( underlyingAssets?: string[] | null, minTvl?: number, showHiddenVaults?: boolean, + feeStructureKey?: string | null, enabled?: boolean ): TOptimizedV2VaultFilterResult { const { vaults, allVaults, getPrice, isLoadingVaultList } = useYearn() @@ -175,6 +178,7 @@ export function useV2VaultFilter( kind, category: deriveAssetCategory(vault), aggressiveness: deriveV3Aggressiveness(vault), + feeStructureKey: getVaultFeeStructureKey(vault), isHidden: Boolean(getVaultInfo(vault)?.isHidden), isActive: Boolean(updates.isActive), isMigratable: Boolean(updates.isMigratable), @@ -265,6 +269,7 @@ export function useV2VaultFilter( const hasCategoryFilter = Boolean(categories?.length) const hasAggressivenessFilter = Boolean(aggressiveness?.length) const hasUnderlyingAssetFilter = normalizedUnderlyingAssets.size > 0 + const hasFeeStructureFilter = Boolean(feeStructureKey) const availableUnderlyingAssets = new Set() const underlyingAssetVaults: Record = {} @@ -286,6 +291,7 @@ export function useV2VaultFilter( kind, category, aggressiveness: aggressivenessScore, + feeStructureKey: entryFeeStructureKey, isHidden, isActive, isMigratable, @@ -324,14 +330,14 @@ export function useV2VaultFilter( isHidden } - const matchesKind = hasUserHoldings || !hasTypeFilter || Boolean(types?.includes(kind)) - const matchesCategory = hasUserHoldings || !hasCategoryFilter || Boolean(categories?.includes(category)) + const matchesKind = !hasTypeFilter || Boolean(types?.includes(kind)) + const matchesCategory = !hasCategoryFilter || Boolean(categories?.includes(category)) const matchesAggressiveness = - hasUserHoldings || !hasAggressivenessFilter || (aggressivenessScore !== null && Boolean(aggressiveness?.includes(aggressivenessScore))) + const matchesFeeStructure = !hasFeeStructureFilter || entryFeeStructureKey === feeStructureKey - if (matchesKind && matchesCategory && matchesAggressiveness) { + if (matchesKind && matchesCategory && matchesAggressiveness && matchesFeeStructure) { const assetKey = normalizeUnderlyingAssetSymbol(getVaultToken(vault)?.symbol) if (assetKey && !underlyingAssetVaults[assetKey]) { availableUnderlyingAssets.add(assetKey) @@ -340,8 +346,7 @@ export function useV2VaultFilter( availableUnderlyingAssets.add(assetKey) } - const matchesUnderlyingAsset = - hasUserHoldings || !hasUnderlyingAssetFilter || (assetKey && expandedUnderlyingAssets.has(assetKey)) + const matchesUnderlyingAsset = !hasUnderlyingAssetFilter || (assetKey && expandedUnderlyingAssets.has(assetKey)) if (!matchesUnderlyingAsset) { return } @@ -369,7 +374,8 @@ export function useV2VaultFilter( searchRegex, lowercaseSearch, isSearchEnabled, - showHiddenVaults + showHiddenVaults, + feeStructureKey ]) return { diff --git a/src/components/shared/hooks/useV3VaultFilter.ts b/src/components/shared/hooks/useV3VaultFilter.ts index 19d6b13c5..20545dd0b 100644 --- a/src/components/shared/hooks/useV3VaultFilter.ts +++ b/src/components/shared/hooks/useV3VaultFilter.ts @@ -13,6 +13,7 @@ import { } from '@pages/vaults/domain/kongVaultSelectors' import { getHoldingsAliasVaultAddress } from '@pages/vaults/domain/normalizeVault' import { DEFAULT_MIN_TVL } from '@pages/vaults/utils/constants' +import { getVaultFeeStructureKey } from '@pages/vaults/utils/vaultFees' import { deriveAssetCategory, deriveListKind, @@ -43,6 +44,7 @@ type TVaultIndexEntry = { kind: ReturnType category: string aggressiveness: TVaultAggressiveness | null + feeStructureKey: string isHidden: boolean isFeatured: boolean isActive: boolean @@ -80,6 +82,7 @@ export function useV3VaultFilter( underlyingAssets?: string[] | null, minTvl?: number, showHiddenVaults?: boolean, + feeStructureKey?: string | null, enabled?: boolean ): TV3VaultFilterResult { const { vaults, allVaults, getPrice, isLoadingVaultList } = useYearn() @@ -181,6 +184,7 @@ export function useV3VaultFilter( kind, category: deriveAssetCategory(vault), aggressiveness: deriveV3Aggressiveness(vault), + feeStructureKey: getVaultFeeStructureKey(vault), isHidden: Boolean(info?.isHidden), isFeatured: Boolean(info?.isHighlighted), isActive: Boolean(updates.isActive), @@ -279,6 +283,7 @@ export function useV3VaultFilter( const hasAggressivenessFilter = Boolean(aggressiveness?.length) const hasTypeFilter = Boolean(types?.length) const hasUnderlyingAssetFilter = normalizedUnderlyingAssets.size > 0 + const hasFeeStructureFilter = Boolean(feeStructureKey) const matchesSearch = (searchableText: string): boolean => { if (!isSearchEnabled) { @@ -298,6 +303,7 @@ export function useV3VaultFilter( kind, category, aggressiveness: aggressivenessScore, + feeStructureKey: entryFeeStructureKey, isHidden, isFeatured, isActive, @@ -351,25 +357,25 @@ export function useV3VaultFilter( totalRetiredMatching++ } - const shouldIncludeByCategory = hasUserHoldings || !hasCategoryFilter || Boolean(categories?.includes(category)) + const shouldIncludeByCategory = !hasCategoryFilter || Boolean(categories?.includes(category)) const isPinnedByUserContext = hasUserHoldings || isMigratableVault || isRetiredVault const isStrategy = kind === 'strategy' const shouldIncludeByFeaturedGate = showHiddenVaults || isStrategy || isFeatured || isPinnedByUserContext const shouldIncludeByKind = - hasUserHoldings || !hasTypeFilter || (Boolean(types?.includes('multi')) && kind === 'allocator') || (Boolean(types?.includes('single')) && kind === 'strategy') const shouldIncludeByAggressiveness = - hasUserHoldings || !hasAggressivenessFilter || (aggressivenessScore !== null && Boolean(aggressiveness?.includes(aggressivenessScore))) + const shouldIncludeByFeeStructure = !hasFeeStructureFilter || entryFeeStructureKey === feeStructureKey if ( shouldIncludeByCategory && shouldIncludeByFeaturedGate && shouldIncludeByKind && - shouldIncludeByAggressiveness + shouldIncludeByAggressiveness && + shouldIncludeByFeeStructure ) { const assetKey = normalizeUnderlyingAssetSymbol(getVaultToken(vault)?.symbol) if (assetKey && !underlyingAssetVaults[assetKey]) { @@ -379,8 +385,7 @@ export function useV3VaultFilter( availableUnderlyingAssets.add(assetKey) } - const matchesUnderlyingAsset = - hasUserHoldings || !hasUnderlyingAssetFilter || (assetKey && expandedUnderlyingAssets.has(assetKey)) + const matchesUnderlyingAsset = !hasUnderlyingAssetFilter || (assetKey && expandedUnderlyingAssets.has(assetKey)) if (matchesUnderlyingAsset) { filteredVaults.push(vault) @@ -412,6 +417,7 @@ export function useV3VaultFilter( minTvlValue, holdingsVaults, showHiddenVaults, + feeStructureKey, searchRegex, lowercaseSearch, isSearchEnabled diff --git a/src/components/shared/icons/IconLockOpen.tsx b/src/components/shared/icons/IconLockOpen.tsx index d520d0a6c..ecf49d88c 100644 --- a/src/components/shared/icons/IconLockOpen.tsx +++ b/src/components/shared/icons/IconLockOpen.tsx @@ -12,7 +12,7 @@ export function IconLockOpen({ className, size = 16 }: TProps): ReactElement { alt="" width={size} height={size} - className={`${className || ''} aspect-square object-cover`} + className={`${className || ''} aspect-square object-contain`} aria-hidden="true" /> ) diff --git a/src/components/shared/utils/format.time.ts b/src/components/shared/utils/format.time.ts index 58f4b75bb..fe0e5bbc8 100755 --- a/src/components/shared/utils/format.time.ts +++ b/src/components/shared/utils/format.time.ts @@ -35,6 +35,4 @@ export function duration(value: number, withSuffix?: boolean): string { return dayjs.duration(value, 'milliseconds').humanize(withSuffix) } -export { date as formatDate } -export { since as formatSince } -export { duration as formatDuration } +export { date as formatDate, duration as formatDuration, since as formatSince } From 9414a34c2c2f0e4cc6ca19b4fa61f8108652e040 Mon Sep 17 00:00:00 2001 From: rossgalloway <58150151+rossgalloway@users.noreply.github.com> Date: Thu, 14 May 2026 19:00:50 +0000 Subject: [PATCH 12/45] update gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 7c91c3e60..95e7a9a38 100755 --- a/.gitignore +++ b/.gitignore @@ -41,6 +41,7 @@ _app # docs /docs/plans +/docs/temp # playwright .playwright-mcp From 566788f90532de299ecb1965cb9681a8a0e0d7cd Mon Sep 17 00:00:00 2001 From: w84april <32621609+w84april@users.noreply.github.com> Date: Fri, 15 May 2026 15:55:46 +0300 Subject: [PATCH 13/45] Fix/pnl misc 2 (#1203) * fix: breakdown pricing * fix: flicker * chore: rm hook and use chart data * test: share data * fix: utc price * fix: annual % * fix: balance calc Co-Authored-By: w84april * chore: cleanup Co-Authored-By: w84april * add yvusd portfolio charts (#1209) * add yvUSD charts * improve yvUSD charts and other fixes * fix dependency * feat: yearn-prices (#1211) * feat: yearn-prices * chore: lint * init * update chart layout and breakpoints * update suggested cards * update placeholder card for chart and chart loading spinners * improve loading input with progress bar * fix chart data error and lint * improve labelling and margins * add timeframe dropdown to vault page charts * consolidate loading spinner types * chore: rm price caching * fix portfolio holdings history consistency * fix staking edge cases * fix misc small review issues * feat: activity style update Co-Authored-By: w84april * feat: support router/zap Co-Authored-By: w84april * fix: symbol Co-Authored-By: w84april * style: icons Co-Authored-By: w84april * feat: filters Co-Authored-By: w84april * fix: mobile Co-Authored-By: w84april * lint * update activity row styling * more styling updates * update dev server startup message * fix list styling, update icons, improve date picker * remove image * fix review issues * back off fetches to prevent 429s * increase rate limit * surface transfers without other events * feat: better decode zaps & fix date range * feat: add mobile * add locker reward claims, ycrv zaps, and better v2 enrichement * improve yBOLD transaction resolution * style: move chain selector * update icons * style: mobile * add zap swaps and old zapper zaps, * fix duplicate keys * clean up build warnings and errors * update mobile styling for activity list * reward claim styling * Feat/progress (#1220) * feat: progress * feat: kill chores * feat: rm fake progress * chore: copy * feat: hash address * chore: cache 1h & do not disable db * Prep for yvBTC (#1185) * fix: classify yvBTC as a yearn vault * feat: pin yvBTC at the top of vaults * fix: surface yvBTC in the default vault list * update filtering * init * adjust spacing * improve vault list row chips and expanded info * add etherscan link to compressed header * add hover effect and more vault info * better styling * improved compressed header title * improve widget clarity * check for swap in zap and display info differently accordingly * Improve lock icon in vault page header * update risk section * more yvBTC prep * Show yvUSD cooldown status in deposits header * fix: show yvUSD cooldown status in mobile header * add cooldown info to deposits panel * generalize cooldown changes to yvBTC * fix issue with neon password * Hide yvBTC from vault lists before launch * lint and update docs * hide dust under $0.01 * fix: clean up more yvBTC stuff * improve chip and yvUSD widget alert styling * fix: stale loading * update gitignore * make vaults and portfolio eager loaded * clean up unconnected and no balance pages and add yvUSD card to suggested * prevent refetch from re-initializing loading * fix mobile styling * fix styling that no one will notice --------- Co-authored-by: rossgalloway <58150151+rossgalloway@users.noreply.github.com> * feat: render controls even with 0 current balance * feat: restore position based growth * fix: hide selector if 1 option --------- Co-authored-by: rossgalloway <58150151+rossgalloway@users.noreply.github.com> --- .gitignore | 1 + api/README.md | 106 + api/holdings/{pnl.ts => activity-facets.ts} | 82 +- api/holdings/activity.test.ts | 145 +- api/holdings/activity.ts | 60 +- api/holdings/chores.ts | 31 - api/holdings/history.test.ts | 38 + api/holdings/history.ts | 156 +- api/holdings/pnl/drilldown.ts | 136 - api/holdings/pnl/simple-history.test.ts | 103 + api/holdings/pnl/simple-history.ts | 150 +- api/holdings/pnl/simple.ts | 115 - api/holdings/progress.ts | 26 + api/holdings/protocol-return/history.ts | 277 ++ api/lib/holdings/PNL.md | 657 ---- api/lib/holdings/README.md | 790 ++--- api/lib/holdings/config.ts | 26 +- api/lib/holdings/db/connection.ts | 75 +- api/lib/holdings/index.ts | 27 +- api/lib/holdings/services/activity.test.ts | 2580 ++++++++++++++- api/lib/holdings/services/activity.ts | 993 +++++- .../services/activityReceiptEnrichment.ts | 1047 +++++++ api/lib/holdings/services/aggregator.test.ts | 219 +- api/lib/holdings/services/aggregator.ts | 284 +- api/lib/holdings/services/cache.test.ts | 30 +- api/lib/holdings/services/cache.ts | 400 +-- api/lib/holdings/services/cow.test.ts | 184 -- api/lib/holdings/services/cow.ts | 413 --- api/lib/holdings/services/debug.ts | 23 +- api/lib/holdings/services/defillama.test.ts | 428 ++- api/lib/holdings/services/defillama.ts | 576 ++-- api/lib/holdings/services/graphql.test.ts | 267 +- api/lib/holdings/services/graphql.ts | 279 +- api/lib/holdings/services/kong.ts | 4 +- .../services/nestedVaultPrices.test.ts | 142 +- .../holdings/services/nestedVaultPrices.ts | 193 +- api/lib/holdings/services/pnl.modes.test.ts | 792 ----- api/lib/holdings/services/pnl.test.ts | 1149 ------- api/lib/holdings/services/pnl.ts | 2777 ----------------- api/lib/holdings/services/pnlEvents.ts | 143 + api/lib/holdings/services/pnlSimple.test.ts | 727 ++++- api/lib/holdings/services/pnlSimple.ts | 524 ++-- .../services/pnlSimpleHistory.test.ts | 154 + api/lib/holdings/services/pnlTypes.ts | 336 -- api/lib/holdings/services/pnlValuation.ts | 319 -- api/lib/holdings/services/progress.ts | 295 ++ api/lib/holdings/services/ratelimit.ts | 22 +- .../services/settledHoldingsContext.ts | 281 ++ api/lib/holdings/services/vaults.ts | 6 +- api/server.ts | 643 ++-- bun.lock | 4 +- src/components/pages/icon-list/index.tsx | 2 +- .../portfolio/components/EmptySectionCard.tsx | 36 +- .../PortfolioHistoryBreakdownModal.tsx | 107 +- .../components/PortfolioHistoryChart.tsx | 613 ++-- .../components/PortfolioVaultGrowthChart.tsx | 85 +- .../portfolio/hooks/buildVaultSuggestions.ts | 10 +- .../portfolio/hooks/usePortfolioActivity.ts | 150 +- .../hooks/usePortfolioHistory.helpers.ts | 32 + .../hooks/usePortfolioHistory.test.ts | 62 + .../portfolio/hooks/usePortfolioHistory.ts | 41 +- .../hooks/usePortfolioHistoryProgress.ts | 69 + .../portfolio/hooks/usePortfolioModel.ts | 124 +- .../pages/portfolio/hooks/usePortfolioPnL.ts | 32 - .../hooks/usePortfolioProtocolReturn.ts | 65 - .../usePortfolioProtocolReturnHistory.ts | 26 +- .../portfolio/hooks/useTokenSuggestions.ts | 33 +- src/components/pages/portfolio/index.tsx | 1996 ++++++++++-- .../pages/portfolio/types/api.test.ts | 129 + src/components/pages/portfolio/types/api.ts | 124 +- .../pages/vaults/[chainID]/[address].tsx | 26 +- .../components/SuggestedVaultCard.test.tsx | 45 +- .../vaults/components/SuggestedVaultCard.tsx | 138 +- .../components/detail/VaultChartsSection.tsx | 63 +- .../components/detail/YvUsdChartsSection.tsx | 114 +- .../components/detail/charts/ChartsLoader.tsx | 7 +- .../filters/VaultsFiltersButton.tsx | 27 +- .../vaults/components/list/VaultsListRow.tsx | 25 +- .../list/VaultsListRowExpandedContent.tsx | 23 +- .../components/list/VirtualizedVaultsList.tsx | 19 +- .../components/widget/InputTokenAmount.tsx | 2 +- .../components/widget/migrate/index.tsx | 2 +- .../components/widget/rewards/index.tsx | 4 +- .../widget/shared/WidgetLoadingSkeleton.tsx | 2 +- .../components/widget/yvUSD/YvUsdDeposit.tsx | 2 +- .../components/widget/yvUSD/YvUsdWithdraw.tsx | 2 +- .../pages/vaults/hooks/useVaultUserHistory.ts | 111 +- .../pages/vaults/hooks/useVaultsPageModel.ts | 2 +- src/components/pages/vaults/index.tsx | 6 +- .../pages/vaults/utils/yvUsd.test.ts | 7 + src/components/pages/vaults/utils/yvUsd.ts | 1 + .../shared/components/YearnLogoSpinner.tsx | 23 + src/components/shared/contexts/useWallet.tsx | 61 +- .../shared/contexts/useYearn.helper.tsx | 12 +- src/components/shared/hooks/useFetch.ts | 5 +- .../shared/icons/IconCalendarDays.tsx | 65 + src/components/shared/icons/IconDeposit.tsx | 21 + .../shared/icons/IconGitCompare.tsx | 6 +- src/components/shared/icons/IconHandCoins.tsx | 23 + src/components/shared/icons/IconLoader.tsx | 2 +- src/components/shared/icons/IconSpinner.tsx | 4 +- src/components/shared/icons/IconStake.tsx | 27 + src/components/shared/icons/IconUnstake.tsx | 27 + src/components/shared/icons/IconWithdraw.tsx | 21 + src/components/shared/utils/fetchers.ts | 29 +- src/routes.tsx | 4 +- src/styles/themes.css | 12 + vercel.json | 6 - vite.config.ts | 12 + 109 files changed, 13151 insertions(+), 10808 deletions(-) create mode 100644 api/README.md rename api/holdings/{pnl.ts => activity-facets.ts} (54%) delete mode 100644 api/holdings/chores.ts delete mode 100644 api/holdings/pnl/drilldown.ts create mode 100644 api/holdings/pnl/simple-history.test.ts delete mode 100644 api/holdings/pnl/simple.ts create mode 100644 api/holdings/progress.ts create mode 100644 api/holdings/protocol-return/history.ts delete mode 100644 api/lib/holdings/PNL.md create mode 100644 api/lib/holdings/services/activityReceiptEnrichment.ts delete mode 100644 api/lib/holdings/services/cow.test.ts delete mode 100644 api/lib/holdings/services/cow.ts delete mode 100644 api/lib/holdings/services/pnl.modes.test.ts delete mode 100644 api/lib/holdings/services/pnl.test.ts delete mode 100644 api/lib/holdings/services/pnl.ts create mode 100644 api/lib/holdings/services/pnlEvents.ts create mode 100644 api/lib/holdings/services/pnlSimpleHistory.test.ts delete mode 100644 api/lib/holdings/services/pnlValuation.ts create mode 100644 api/lib/holdings/services/progress.ts create mode 100644 api/lib/holdings/services/settledHoldingsContext.ts create mode 100644 src/components/pages/portfolio/hooks/usePortfolioHistory.helpers.ts create mode 100644 src/components/pages/portfolio/hooks/usePortfolioHistory.test.ts create mode 100644 src/components/pages/portfolio/hooks/usePortfolioHistoryProgress.ts delete mode 100644 src/components/pages/portfolio/hooks/usePortfolioPnL.ts delete mode 100644 src/components/pages/portfolio/hooks/usePortfolioProtocolReturn.ts create mode 100644 src/components/pages/portfolio/types/api.test.ts create mode 100644 src/components/shared/components/YearnLogoSpinner.tsx create mode 100644 src/components/shared/icons/IconCalendarDays.tsx create mode 100644 src/components/shared/icons/IconDeposit.tsx create mode 100644 src/components/shared/icons/IconHandCoins.tsx create mode 100644 src/components/shared/icons/IconStake.tsx create mode 100644 src/components/shared/icons/IconUnstake.tsx create mode 100644 src/components/shared/icons/IconWithdraw.tsx diff --git a/.gitignore b/.gitignore index 95e7a9a38..129798d8b 100755 --- a/.gitignore +++ b/.gitignore @@ -57,3 +57,4 @@ yearn.fi-worktree-* # codex settings .codex .codex/* +.gstack/ diff --git a/api/README.md b/api/README.md new file mode 100644 index 000000000..d58b573cf --- /dev/null +++ b/api/README.md @@ -0,0 +1,106 @@ +# 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_SERVER_PORT` overrides the default `3001`. + +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/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/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`, `destinationChainId`, and `receiver`. +- `/api/enso/balances` sets `Cache-Control: public, s-maxage=60, stale-while-revalidate=300`. + +## 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_SERVER_PORT` | local server | Local Bun API port, default `3001` | +| `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 | Envio GraphQL endpoint | +| `ENVIO_PASSWORD` | holdings | Optional Envio Hasura admin secret | +| `DATABASE_URL_PREVIEW` | holdings | Preferred holdings PostgreSQL URL when set | +| `DATABASE_URL` | holdings | Default holdings PostgreSQL URL | +| `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` | +| `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/holdings/pnl.ts b/api/holdings/activity-facets.ts similarity index 54% rename from api/holdings/pnl.ts rename to api/holdings/activity-facets.ts index 30dfd4fcb..06ff0713a 100644 --- a/api/holdings/pnl.ts +++ b/api/holdings/activity-facets.ts @@ -1,5 +1,5 @@ import type { VercelRequest, VercelResponse } from '@vercel/node' -import type { HoldingsEventFetchType, HoldingsEventPaginationMode } from '../lib/holdings' +import type { VaultVersion } from '../lib/holdings' import { checkRateLimit, ensureSchemaInitialized } from '../lib/holdings' function simpleHash(str: string): string { @@ -26,16 +26,22 @@ function isValidAddress(address: string): boolean { return /^0x[a-fA-F0-9]{40}$/.test(address) } -function parseUnknownTransferInPnlMode(value: unknown): 'strict' | 'zero_basis' | 'windfall' { - return value === 'strict' || value === 'zero_basis' || value === 'windfall' ? value : 'windfall' +function parseVersion(value: string | string[] | undefined): VaultVersion { + return value === 'v2' || value === 'v3' ? value : 'all' } -function parseHoldingsEventFetchType(value: string | string[] | undefined): HoldingsEventFetchType { - return value === 'parallel' ? 'parallel' : 'seq' +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 parseHoldingsEventPaginationMode(value: string | string[] | undefined): HoldingsEventPaginationMode { - return value === 'all' ? 'all' : 'paged' +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) { @@ -54,7 +60,7 @@ export default async function handler(req: VercelRequest, res: VercelResponse) { try { await ensureSchemaInitialized() } catch (error) { - console.error('Holdings PnL schema initialization error:', error) + console.error('Holdings activity facets schema initialization error:', error) return res.status(500).json({ error: 'Failed to initialize holdings storage' }) } @@ -68,12 +74,17 @@ export default async function handler(req: VercelRequest, res: VercelResponse) { const envioUrl = process.env.ENVIO_GRAPHQL_URL if (!envioUrl) { return res.status(503).json({ - error: 'Holdings PnL API not configured', + 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, unknownMode, fetchType: fetchTypeParam, paginationMode: paginationModeParam } = req.query + 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' }) @@ -83,38 +94,51 @@ export default async function handler(req: VercelRequest, res: VercelResponse) { return res.status(400).json({ error: 'Invalid Ethereum address' }) } - const fetchType = parseHoldingsEventFetchType(fetchTypeParam) - const paginationMode = parseHoldingsEventPaginationMode(paginationModeParam) - try { - const { getHoldingsPnL } = await import('../lib/holdings') - const pnl = await getHoldingsPnL( + 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 === 'v2' || version === 'v3' ? version : 'all', - parseUnknownTransferInPnlMode(Array.isArray(unknownMode) ? unknownMode[0] : unknownMode), - fetchType, - paginationMode + version, + limitPerSource, + undefined, + offsetPerSource ) - - if (pnl.summary.totalVaults === 0) { - return res.status(404).json({ error: 'No holdings found for address' }) - } - - res.setHeader('Cache-Control', 'public, s-maxage=300, stale-while-revalidate=600') - return res.status(200).json(pnl) + 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 PnL error:', 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 PnL', + error: 'Failed to fetch holdings activity facets', message, stack }) } - return res.status(502).json({ error: 'Failed to fetch holdings PnL' }) + 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 index cc5259204..368567393 100644 --- a/api/holdings/activity.test.ts +++ b/api/holdings/activity.test.ts @@ -69,6 +69,7 @@ describe('holdings activity route', () => { txHash: '0xabc', timestamp: 1776902400, action: 'deposit', + transferDirection: null, vaultAddress: '0xbe53a109b494e5c9f97b9cd39fe969be68bf6204', familyVaultAddress: '0xbe53a109b494e5c9f97b9cd39fe969be68bf6204', assetSymbol: 'USDC', @@ -95,7 +96,19 @@ describe('holdings activity route', () => { await handler(req, res as any) - expect(getHoldingsActivityMock).toHaveBeenCalledWith(TEST_ADDRESS, 'all', 10, 0) + 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, @@ -112,6 +125,7 @@ describe('holdings activity route', () => { txHash: '0xabc', timestamp: 1776902400, action: 'deposit', + transferDirection: null, vaultAddress: '0xbe53a109b494e5c9f97b9cd39fe969be68bf6204', familyVaultAddress: '0xbe53a109b494e5c9f97b9cd39fe969be68bf6204', assetSymbol: 'USDC', @@ -163,4 +177,133 @@ describe('holdings activity route', () => { 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 index e6db6e82a..aed8507eb 100644 --- a/api/holdings/activity.ts +++ b/api/holdings/activity.ts @@ -1,5 +1,5 @@ import type { VercelRequest, VercelResponse } from '@vercel/node' -import type { VaultVersion } from '../lib/holdings' +import type { HoldingsActivityTypeFilter, VaultVersion } from '../lib/holdings' import { checkRateLimit, ensureSchemaInitialized } from '../lib/holdings' function simpleHash(str: string): string { @@ -52,6 +52,43 @@ function parseOffset(value: string | string[] | undefined): number { 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') @@ -87,7 +124,17 @@ export default async function handler(req: VercelRequest, res: VercelResponse) { }) } - const { address, version: versionParam, limit: limitParam, offset: offsetParam } = req.query + 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' }) @@ -103,7 +150,14 @@ export default async function handler(req: VercelRequest, res: VercelResponse) { address, parseVersion(versionParam), parseLimit(limitParam), - parseOffset(offsetParam) + 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') diff --git a/api/holdings/chores.ts b/api/holdings/chores.ts deleted file mode 100644 index a37ff791a..000000000 --- a/api/holdings/chores.ts +++ /dev/null @@ -1,31 +0,0 @@ -import type { VercelRequest, VercelResponse } from '@vercel/node' -import { deleteStaleCache, ensureSchemaInitialized } from '../lib/holdings' - -export default async function handler(req: VercelRequest, res: VercelResponse) { - // Verify cron secret - const authHeader = req.headers.authorization - const cronSecret = process.env.CRON_SECRET - - if (!cronSecret) { - console.error('[Chores] CRON_SECRET not configured') - return res.status(500).json({ error: 'Server misconfigured' }) - } - - if (authHeader !== `Bearer ${cronSecret}`) { - return res.status(401).json({ error: 'Unauthorized' }) - } - - try { - await ensureSchemaInitialized() - const deletedCount = await deleteStaleCache() - - return res.status(200).json({ - success: true, - deletedRows: deletedCount, - timestamp: new Date().toISOString() - }) - } catch (error) { - console.error('[Chores] Failed to run cleanup:', error) - return res.status(500).json({ error: 'Cleanup failed' }) - } -} diff --git a/api/holdings/history.test.ts b/api/holdings/history.test.ts index 0c846b767..d7aeeca76 100644 --- a/api/holdings/history.test.ts +++ b/api/holdings/history.test.ts @@ -88,4 +88,42 @@ describe('holdings history route', () => { ] }) }) + + it('passes multi-vault filters to historical holdings chart', async () => { + getHistoricalHoldingsChartMock.mockResolvedValue({ + address: '0xA7b6f3d18db39F65C8056d0892Af76c07d15Fc5a', + 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: '0xA7b6f3d18db39F65C8056d0892Af76c07d15Fc5a', + 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( + '0xA7b6f3d18db39F65C8056d0892Af76c07d15Fc5a', + '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 index 2ad8fb662..00e6b16bf 100644 --- a/api/holdings/history.ts +++ b/api/holdings/history.ts @@ -7,6 +7,14 @@ import type { VaultVersion } from '../lib/holdings' import { checkRateLimit, ensureSchemaInitialized } 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 @@ -35,6 +43,62 @@ 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' } @@ -91,11 +155,16 @@ export default async function handler(req: VercelRequest, res: VercelResponse) { const { address, + chainId: chainIdParam, + vault: vaultParam, + vaults: vaultsParam, version: versionParam, fetchType: fetchTypeParam, paginationMode: paginationModeParam, denomination: denominationParam, - timeframe: timeframeParam + timeframe: timeframeParam, + debug: debugParam, + progressId: progressIdParam } = req.query if (!address || typeof address !== 'string') { @@ -106,27 +175,93 @@ export default async function handler(req: VercelRequest, res: VercelResponse) { 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 { getHistoricalHoldingsChart } = await import('../lib/holdings') - const holdings = await getHistoricalHoldingsChart( + const activeProgressId = await startHoldingsProgress({ + id: progressId, + route: 'history', address, - version, - fetchType, - paginationMode, - denomination, - timeframe + 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, @@ -139,6 +274,11 @@ export default async function handler(req: VercelRequest, res: VercelResponse) { })) }) } 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') { diff --git a/api/holdings/pnl/drilldown.ts b/api/holdings/pnl/drilldown.ts deleted file mode 100644 index aa3710fac..000000000 --- a/api/holdings/pnl/drilldown.ts +++ /dev/null @@ -1,136 +0,0 @@ -import type { VercelRequest, VercelResponse } from '@vercel/node' -import type { HoldingsEventFetchType, HoldingsEventPaginationMode } from '../../lib/holdings' -import { checkRateLimit, ensureSchemaInitialized } 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 parseUnknownTransferInPnlMode(value: unknown): 'strict' | 'zero_basis' | 'windfall' { - return value === 'strict' || value === 'zero_basis' || value === 'windfall' ? value : 'windfall' -} - -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 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 ensureSchemaInitialized() - } catch (error) { - console.error('Holdings PnL drilldown schema 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 PnL drilldown API not configured', - details: 'ENVIO_GRAPHQL_URL environment variable is not set. This feature requires a running Envio indexer.' - }) - } - - const { - address, - vault, - version, - unknownMode, - 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' }) - } - - if (vault !== undefined && (typeof vault !== 'string' || !isValidAddress(vault))) { - return res.status(400).json({ error: 'Invalid vault address' }) - } - - const fetchType = parseHoldingsEventFetchType(fetchTypeParam) - const paginationMode = parseHoldingsEventPaginationMode(paginationModeParam) - - try { - const { getHoldingsPnLDrilldown } = await import('../../lib/holdings') - const pnl = await getHoldingsPnLDrilldown( - address, - version === 'v2' || version === 'v3' ? version : 'all', - parseUnknownTransferInPnlMode(Array.isArray(unknownMode) ? unknownMode[0] : unknownMode), - fetchType, - paginationMode, - vault - ) - - if (pnl.summary.totalVaults === 0) { - return res.status(404).json({ - error: vault ? 'No matching holdings found for address and vault' : 'No holdings found for address' - }) - } - - res.setHeader('Cache-Control', 'public, s-maxage=300, stale-while-revalidate=600') - return res.status(200).json(pnl) - } catch (error) { - console.error('Holdings PnL drilldown 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 PnL drilldown', - message, - stack - }) - } - - return res.status(502).json({ error: 'Failed to fetch holdings PnL drilldown' }) - } -} diff --git a/api/holdings/pnl/simple-history.test.ts b/api/holdings/pnl/simple-history.test.ts new file mode 100644 index 000000000..5fa10e2ab --- /dev/null +++ b/api/holdings/pnl/simple-history.test.ts @@ -0,0 +1,103 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const ensureSchemaInitializedMock = vi.fn() +const checkRateLimitMock = vi.fn() +const getHoldingsProtocolReturnHistoryMock = vi.fn() + +vi.mock('../../lib/holdings', () => ({ + ensureSchemaInitialized: ensureSchemaInitializedMock, + 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() + ensureSchemaInitializedMock.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: '0xa7b6f3d18db39f65c8056d0892af76c07d15fc5a', + 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: '0xA7b6f3d18db39F65C8056d0892Af76c07d15Fc5a', + 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( + '0xA7b6f3d18db39F65C8056d0892Af76c07d15Fc5a', + '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 index b8d36a4f7..b1be31ab4 100644 --- a/api/holdings/pnl/simple-history.ts +++ b/api/holdings/pnl/simple-history.ts @@ -1,149 +1 @@ -import type { VercelRequest, VercelResponse } from '@vercel/node' -import type { - HoldingsEventFetchType, - HoldingsEventPaginationMode, - HoldingsHistoryTimeframe, - VaultVersion -} from '../../lib/holdings' -import { checkRateLimit, ensureSchemaInitialized } 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 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 ensureSchemaInitialized() - } catch (error) { - console.error('Holdings simple PnL history schema 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 simple PnL 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, - version: versionParam, - fetchType: fetchTypeParam, - paginationMode: paginationModeParam, - timeframe: timeframeParam - } = 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' }) - } - - if (vaultParam !== undefined && typeof vaultParam !== 'string') { - return res.status(400).json({ error: 'Invalid vault address' }) - } - - if (vaultParam && !isValidAddress(vaultParam)) { - return res.status(400).json({ error: 'Invalid vault address' }) - } - - if (vaultParam && (!chainIdParam || typeof chainIdParam !== 'string' || !Number.isInteger(Number(chainIdParam)))) { - return res.status(400).json({ error: 'Missing or invalid chainId for vault filter' }) - } - - const version: VaultVersion = versionParam === 'v2' || versionParam === 'v3' ? versionParam : 'all' - const fetchType = parseHoldingsEventFetchType(fetchTypeParam) - const paginationMode = parseHoldingsEventPaginationMode(paginationModeParam) - const timeframe = parseHoldingsHistoryTimeframe(timeframeParam) - - try { - const { getHoldingsPnLSimpleHistory } = await import('../../lib/holdings') - const history = await getHoldingsPnLSimpleHistory( - address, - version, - fetchType, - paginationMode, - timeframe, - vaultParam, - vaultParam ? Number(chainIdParam) : undefined - ) - - if (history.summary.totalVaults === 0) { - return res.status(404).json({ error: 'No holdings found for address' }) - } - - res.setHeader('Cache-Control', 'public, s-maxage=300, stale-while-revalidate=600') - return res.status(200).json(history) - } catch (error) { - console.error('Holdings simple PnL 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 simple PnL history', - message, - stack - }) - } - - return res.status(502).json({ error: 'Failed to fetch holdings simple PnL history' }) - } -} +export { default } from '../protocol-return/history' diff --git a/api/holdings/pnl/simple.ts b/api/holdings/pnl/simple.ts deleted file mode 100644 index bd5f0846d..000000000 --- a/api/holdings/pnl/simple.ts +++ /dev/null @@ -1,115 +0,0 @@ -import type { VercelRequest, VercelResponse } from '@vercel/node' -import type { HoldingsEventFetchType, HoldingsEventPaginationMode } from '../../lib/holdings' -import { checkRateLimit, ensureSchemaInitialized } 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 parseHoldingsEventFetchType(value: string | string[] | undefined): HoldingsEventFetchType { - return value === 'parallel' ? 'parallel' : 'seq' -} - -function parseHoldingsEventPaginationMode(value: string | string[] | undefined): HoldingsEventPaginationMode { - return value === 'all' ? 'all' : 'paged' -} - -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 ensureSchemaInitialized() - } catch (error) { - console.error('Holdings simple PnL schema 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 simple PnL API not configured', - details: 'ENVIO_GRAPHQL_URL environment variable is not set. This feature requires a running Envio indexer.' - }) - } - - const { address, version, 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 fetchType = parseHoldingsEventFetchType(fetchTypeParam) - const paginationMode = parseHoldingsEventPaginationMode(paginationModeParam) - - try { - const { getHoldingsPnLSimple } = await import('../../lib/holdings') - const pnl = await getHoldingsPnLSimple( - address, - version === 'v2' || version === 'v3' ? version : 'all', - fetchType, - paginationMode - ) - - if (pnl.summary.totalVaults === 0) { - return res.status(404).json({ error: 'No holdings found for address' }) - } - - res.setHeader('Cache-Control', 'public, s-maxage=300, stale-while-revalidate=600') - return res.status(200).json(pnl) - } catch (error) { - console.error('Holdings simple PnL 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 simple PnL', - message, - stack - }) - } - - return res.status(502).json({ error: 'Failed to fetch holdings simple PnL' }) - } -} 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..2f41a825d --- /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, ensureSchemaInitialized } 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 ensureSchemaInitialized() + } catch (error) { + console.error('Holdings protocol return history schema 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/PNL.md b/api/lib/holdings/PNL.md deleted file mode 100644 index 7dffc94bc..000000000 --- a/api/lib/holdings/PNL.md +++ /dev/null @@ -1,657 +0,0 @@ -# Holdings PnL Logic - -This document explains how `GET /api/holdings/pnl`, `GET /api/holdings/pnl/drilldown`, and `GET /api/holdings/pnl/simple` work today. - -It is written for product engineers and contributors who need to understand the accounting model without reading the full implementation in `api/lib/holdings/services/pnl.ts`. - -## Goal - -The endpoint answers a practical portfolio question: - -> For a given wallet, what vault positions exist now, and how much profit or loss can we attribute to them? - -The answer is intentionally conservative when the underlying history is ambiguous. - -## What The Endpoint Is - -The endpoint is: - -- A Yearn vault portfolio PnL calculator -- FIFO-based for known lots -- A wallet-level aggregator across vault families -- Conservative when cost basis cannot be reconstructed - -The endpoint is not: - -- A tax engine -- A generic ERC20 PnL engine -- A full wallet-wide cash-flow ledger -- A proof that every historical transfer has been classified perfectly - -## Public Surfaces - -The backend now exposes two PnL shapes: - -- `GET /api/holdings/pnl` - - compact portfolio summary plus one row per vault family - - intended for overview cards, value tables, filters, and composition views -- `GET /api/holdings/pnl/drilldown` - - the same valuation basis, but expanded with current lots, realized lot consumption, unknown-basis receipts and withdrawals, and a transaction journal - - intended for vault drawers, lot timelines, and accounting inspection UI -- `GET /api/holdings/pnl/simple` - - protocol-return summary based on withdrawable-underlying growth while the user held vault shares - - intended for performance UI that wants to ignore later asset price movement and avoid full cost-basis reconstruction - -## Core Idea - -The engine models each vault family as a set of share lots. - -A lot has: - -- A share amount -- A location - - vault - - staked -- An acquisition timestamp -- A cost basis in underlying assets, or `null` when basis is unknown - -For known-basis lots, USD basis is derived later from: - -- the lot's underlying asset basis -- the lot's acquisition timestamp -- the underlying token price at that acquisition timestamp - -Known-basis lots come from indexed deposit / withdrawal context. -Known-basis lots can also come from recognized synthetic acquisition flows, such as supported CoW settlement receipt enrichment on Ethereum mainnet. -Known-basis lots can also come from recognized zero-basis reward receipts, where the engine can identify a distributor flow that should be treated as a reward rather than as an unknown transfer-in. -Known-basis lots can also come from recognized vault-to-vault rollover flows, where source vault lots are consumed and their basis is redistributed into the destination vault shares. -Unknown-basis lots usually come from share transfers where the economic source cannot be proven. - -## Mental Model - -Think in this order: - -1. Build raw user-visible events from the indexer. -2. Enrich those events with same-transaction context. -3. Group events into vault families. -4. Build FIFO lots for each family. -5. Value remaining lots at current PPS and token price. -6. Apply an unknown-transfer policy: `strict`, `zero_basis`, or `windfall`. - -## Simple Protocol Return - -`GET /api/holdings/pnl/simple` is a separate metric, not a faster version of the full accounting route. - -It answers: - -> How much did Yearn increase the user’s withdrawable underlying amount while the user held vault shares? - -It does not answer: - -- What was the user’s accounting cost basis? -- What was the mark-to-market USD PnL after the asset price moved? -- Was every router / migrator / reward transfer classified for accounting purposes? - -The simple route uses only address-scoped events: - -- direct deposits -- direct withdrawals -- vault-token transfers in -- vault-token transfers out - -It intentionally skips transaction-hash enrichment. That means it avoids the expensive same-transaction fetches used by the full PnL route for CoW settlements, Enso rollovers, vault-to-vault basis carrying, and unknown-transfer classification. - -For each receipt, the simple route records: - -- shares received -- baseline underlying amount -- receipt timestamp -- receipt-time token price - -Deposits use indexed `assets` as the baseline. Transfer-ins use `shares * PPS at receipt`. - -For each exit, the route consumes receipt lots FIFO and compares the exit withdrawable underlying amount to the consumed baseline: - -- withdrawals use indexed `assets` -- transfer-outs use `shares * PPS at exit` - -Open lots compare current withdrawable underlying to their receipt baseline. - -The percentage is weighted across assets with receipt-time token price: - -```text -baselineWeightUsd = baselineUnderlying * receiptTokenPriceUsd -growthWeightUsd = growthUnderlying * receiptTokenPriceUsd -protocolReturnPct = growthWeightUsd / baselineWeightUsd * 100 -``` - -Because both numerator and denominator use the same receipt-time price, later token price movement does not affect `protocolReturnPct`. The metric is driven by PPS / withdrawable-underlying growth only. - -The route now also exposes an annualized variant built from time-weighted baseline exposure: - -```text -baselineExposureWeightUsdYears = - integral over time of (baselineUnderlying * receiptTokenPriceUsd) - --------------------------------------------------------------- - seconds per year - -annualizedProtocolReturnPct = - growthWeightUsd / baselineExposureWeightUsdYears * 100 -``` - -This is intended for high-turnover wallets where cumulative receipts can materially exceed the capital that was actually sitting in vaults at any one time. - -Short explainers for the chart surfaces live in: - -- [docs/simple-protocol-return-growth-chart.md](../../../docs/simple-protocol-return-growth-chart.md) -- [docs/simple-protocol-return-cumulative-return-chart.md](../../../docs/simple-protocol-return-cumulative-return-chart.md) -- [docs/simple-protocol-return-annualized-return-chart.md](../../../docs/simple-protocol-return-annualized-return-chart.md) -- [docs/simple-protocol-return-growth-index-chart.md](../../../docs/simple-protocol-return-growth-index-chart.md) - -## Vault Families - -The engine does not treat the staking wrapper as a separate investment from the underlying vault. - -A family is: - -- the underlying vault -- plus its staking wrapper, if one exists - -This matters because stake and unstake actions should usually move lots between locations, not create fresh cost basis. - -## Data Sources - -The PnL endpoint uses four main inputs. - -### 1. Envio GraphQL - -Used for: - -- underlying deposits and withdrawals -- staking deposits and withdrawals -- share transfers in and out - -### 2. Transaction-Scoped Enrichment - -Address-scoped events are often not enough. - -The PnL pipeline therefore also loads additional events from the same transaction hashes so it can understand: - -- router flows -- stake / unstake flows -- same-transaction deposit plus transfer combinations -- known migration rollovers - -Without that enrichment, many transfers would stay permanently ambiguous. - -For a small set of recognized Ethereum mainnet CoW settlement flows, the pipeline also inspects the transaction receipt and settlement logs so it can synthesize a known-basis acquisition instead of treating the received position as an unknown transfer-in. - -### 3. Kong PPS - -Historical and current price-per-share is used to translate vault shares into underlying asset amounts. - -### 4. DefiLlama Prices - -Historical and current underlying token prices are used to express PnL in USD. - -In this document, "underlying token" means the vault asset token from Kong metadata, not the Yearn share token itself. For some vaults that asset token is an LP token or pool token rather than a plain ERC20 like USDC. - -That includes: - -- acquisition-time token prices for known lot USD basis -- receipt-time token prices for unknown-lot windfall valuation -- realization-time token prices for realized USD proceeds -- current token prices for current market value - -## Processing Flow - -The implementation has two major stages. - -### Stage 1: Build Family Ledgers - -Raw events are normalized into a transaction journal. - -The ledger builder tracks: - -- vault-share lots -- staked lots -- realized entries -- recognized reward transfer-in entries -- unknown transfer-in entries -- withdrawals that consumed unknown-basis shares -- unmatched transfer-outs - -### Stage 2: Materialize API Rows - -Each family ledger is then valued into a response row with: - -- current value -- known vs unknown basis share counts -- realized PnL -- unrealized PnL -- windfall PnL when applicable -- completeness flags - -## How Known Basis Works - -### Deposits - -Underlying vault `Deposit` events create known-basis lots. - -The lot basis is the indexed `assets` amount. -The lot also keeps its acquisition timestamp. - -### Withdrawals - -Underlying vault `Withdraw` events consume existing lots using FIFO. - -For known lots: - -- proceeds come from the indexed `assets` -- consumed basis comes from the oldest remaining lots -- realized PnL is `proceeds - consumed_basis` - -In USD terms: - -- realized proceeds use the underlying token price at the withdrawal timestamp -- consumed basis uses the underlying token price at each consumed lot's acquisition timestamp - -### Unrealized PnL - -For known lots that still remain: - -- current underlying value = `shares * current PPS` -- unrealized PnL = `current underlying value - remaining cost basis` - -In USD terms: - -- current value uses the current underlying token price -- remaining basis uses the acquisition-time token price of each remaining lot -- unrealized USD PnL = `current value USD - remaining basis USD` - -This means the endpoint now captures both: - -- vault/share performance via PPS changes -- underlying token price changes between deposit and withdrawal / current time - -## How Staking Works - -Staking deposits and withdrawals are not treated as new investments by default. - -Instead, they usually mean: - -- vault shares moved into staked location -- or staked shares moved back to vault location - -The lot should stay the same. Only its location changes. - -This avoids fake realized PnL when the user simply stakes or unstakes a Yearn position. - -## How Router Flows Work - -Some transactions do not emit the economically useful event directly on the user address. - -Example: - -- user interacts with a router -- router deposits into the vault -- shares are transferred to a staking wrapper -- the user only appears on part of that flow - -The tx-hash enrichment step lets the engine inspect the whole transaction and attribute basis to the correct family. - -For recognized CoW settlement buys, the engine can synthesize a deposit-like acquisition from the receipt by combining: - -- the settlement trade log -- the asset transfer into the vault -- the share mint from the vault - -This lets the destination position start as a known-basis lot instead of a partial transfer-in when the flow is sufficiently unambiguous. - -## How Migrations Work - -The engine supports recognized migration paths. - -There are two broad cases: - -### Same-Family Rollovers - -These are transactions where shares exit and re-enter within the same family in a way that should preserve basis. - -### Known Cross-Family Migrators - -For specific migrator flows, basis can be rolled from a source family into a destination family. - -If the source side cannot be reconstructed confidently, the destination side remains partial. - -## Plain Transfers - -Plain transfers are the hardest part of the model. - -If shares arrive and the engine cannot prove where their basis came from, those shares become unknown-basis lots. - -If shares leave and the engine cannot match them to known or unknown lots cleanly, the ledger records unmatched transfer-out state. - -This is why the endpoint exposes completeness metadata instead of pretending every vault is fully known. - -## Recognized Reward Receipts - -Some transfer-ins are not treated as unknown. - -When the engine can match an incoming share transfer to a recognized reward distributor for that vault asset token, it classifies the receipt as: - -- a reward transfer-in -- a known lot with `costBasis = 0` -- a complete-basis receipt, not a partial / unknown one - -That means: - -- `costBasisStatus` stays `complete` for that receipt path -- the receipt does not contribute to `unknownCostBasisValueUsd` -- the receipt does not use `windfallPnlUsd` -- all later value is reported as normal realized / unrealized PnL on a zero-basis lot - -Today this path is intended for explicit known reward-distribution flows, not for generic airdrops or arbitrary transfers. - -## Recognized Vault Rollovers - -Some transfer-ins are not rewards and are not unknown. - -When the engine can prove that a transaction is a vault-to-vault rollover, it classifies the receipt as: - -- a migration / rollover from a source family into a destination family -- a known-basis receipt whose destination lots inherit source-lot basis -- a complete-basis receipt unless the source side already had unknown basis - -That means: - -- the destination path increments `eventCounts.migrationsIn` -- the source path increments `eventCounts.migrationsOut` -- destination shares do not become `unknownTransferInEntries` just because a router or intermediary sat in the middle -- basis is preserved across the rollover instead of being reset to zero or marked unknown - -Today this path covers explicit known migrator flows, supported Enso-mediated vault rollovers, and supported compatible nested-vault paths where one Yearn vault token is the asset of another Yearn vault. The current compatible nested-vault allowlist includes the `ysyBOLD <-> yBOLD` path on Ethereum mainnet. It is intentionally pattern-based rather than a blanket router whitelist. - -## Unknown Transfer-In Modes - -The endpoint supports three policies for unknown transfer-ins. - -Query param: - -```text -unknownMode=strict|zero_basis|windfall -``` - -Default: - -```text -windfall -``` - -## Request Controls - -The PnL endpoint also exposes event-loading controls for debugging and benchmarking: - -```text -fetchType=seq|parallel -paginationMode=paged|all -``` - -Defaults: - -```text -fetchType=seq -paginationMode=paged -``` - -### `fetchType` - -- `seq` - - address-scoped event families paginate with the normal `1000`-row page walker -- `parallel` - - address-scoped event families first try GraphQL aggregate counts, then request multiple `1000`-row pages in parallel - -This only affects the address-scoped family fetch path. Transaction-hash enrichment still happens afterwards. - -### `paginationMode` - -- `paged` - - use normal `limit/offset` pagination with `1000`-row pages -- `all` - - request each address-scoped and `transactionFrom` event family in a single query using a large hard limit instead of paging - -`all` is intended for experimentation. It bypasses the aggregate count preflight, but it uses a fixed single-query limit internally, so it should be treated as a benchmarking/debug option rather than a universally safe default. - -### `strict` - -Use this when you want the most conservative answer. - -Behavior: - -- unknown-basis shares do not contribute to realized or unrealized PnL -- their current value is reported in `unknownCostBasisValueUsd` -- `totalPnlUsd` only reflects lots with known basis - -### `zero_basis` - -Use this when you want an upper-bound estimate and are willing to assume unknown transfers were free economically. - -Behavior: - -- unknown-basis shares are treated as if basis were zero -- the full value of those shares flows into realized or unrealized PnL - -### `windfall` - -This is the default because it preserves the same economic assumption as `zero_basis`, but with better attribution. - -Behavior: - -- receipt-time fair value of unknown shares goes to `windfallPnlUsd` -- only the market move after receipt goes to `totalPnlUsd` -- `totalEconomicGainUsd = totalPnlUsd + totalWindfallPnlUsd` - -### Simple Example - -Assume: - -- unknown shares are received when worth `$1,000` -- later they are worth `$1,150` - -Results: - -```text -strict: - totalPnlUsd = 0 - unknownCostBasisValueUsd = 1,150 - -zero_basis: - totalPnlUsd = 1,150 - totalEconomicGainUsd = 1,150 - -windfall: - totalWindfallPnlUsd = 1,000 - totalPnlUsd = 150 - totalEconomicGainUsd = 1,150 -``` - -## Known-Basis USD Example - -Assume: - -- a user deposits into a WETH vault -- deposit receives shares representing `1 WETH` -- WETH is `$2,000` at deposit time -- PPS is unchanged -- later those shares still represent `1 WETH` -- WETH is now `$3,000` - -Then: - -- underlying unrealized PnL = `0 WETH` -- unrealized USD PnL = `$1,000` - -That is intentional. The endpoint now reports full USD mark-to-market PnL for known-basis lots, not only asset-denominated vault performance. - -So: - -- `zero_basis` and `windfall` can have the same economic gain -- they differ only in how that gain is classified - -## Response Fields - -### Summary - -- `totalCurrentValueUsd` - - Sum of current vault values for returned rows -- `totalUnknownCostBasisValueUsd` - - Current value of unknown-basis holdings in `strict` mode -- `totalWindfallPnlUsd` - - Receipt-time value isolated by `windfall` mode -- `totalRealizedPnlUsd` - - PnL from lots that have exited through recognized withdrawal flows -- `totalUnrealizedPnlUsd` - - PnL on lots that are still held -- `totalPnlUsd` - - `totalRealizedPnlUsd + totalUnrealizedPnlUsd` -- `totalEconomicGainUsd` - - `totalPnlUsd + totalWindfallPnlUsd` - -### Per-Vault Completeness - -- `status` - - `ok`, `missing_metadata`, `missing_price`, or `missing_pps` -- `costBasisStatus` - - `complete` or `partial` - -A vault is `partial` when the engine still has ambiguity, such as: - -- unknown transfer-in shares -- withdrawals that consumed unknown-basis shares -- unmatched transfer-outs - -### Per-Vault Current/Basis Fields - -The compact endpoint now also exposes explicit basis and underlying fields: - -- `currentUnderlying` - - current underlying asset amount represented by all current shares -- `vaultUnderlying` - - current underlying amount still in the direct vault-share location -- `stakedUnderlying` - - current underlying amount in the staking location -- `currentKnownUnderlying` - - current underlying amount attributed to known-basis lots -- `currentUnknownUnderlying` - - current underlying amount attributed to unknown-basis lots -- `knownCostBasisUnderlying` - - summed underlying asset basis from known lots -- `knownCostBasisUsd` - - USD-marked basis of known lots using acquisition-time token prices - -These fields are useful for UI without forcing a drilldown request. - -### Drilldown Fields - -The drilldown endpoint adds per-vault: - -- `currentLots.vault` -- `currentLots.staked` -- `realizedEntries` -- `rewardTransferInEntries` -- `unknownTransferInEntries` -- `unknownWithdrawalEntries` -- `journal` - -The journal is transaction-oriented. It records: - -- the computed vault-family view for that transaction -- whether the wallet had direct address activity in that transaction -- realized / stake / unstake / reward / unknown-transfer deltas -- lot summaries before and after the transaction for vault-share and staked-share locations - -For frontend drilldown: - -- `rewardTransferInEntries` should be rendered separately from `unknownTransferInEntries` -- reward entries are known zero-basis receipts, so they should not be styled as accounting ambiguity -- `txHash` currently lives on `journal`, not directly on each reward entry -- if the UI wants explorer links for reward rows, it should match a reward entry to the journal row with the same: - - `timestamp` - - `rewardInVaultShares` or `rewardInStakedShares` - -## What Is Covered Well Today - -The current implementation is strong on: - -- direct deposit / withdraw accounting -- FIFO cost basis for known lots -- stake and unstake handling for staking wrappers -- same-transaction router context, when events can be linked by tx hash -- known migration paths -- explicit treatment of unknown basis instead of silently inventing numbers - -## What Is Still Conservative Or Incomplete - -The current implementation is not fully complete in the absolute sense. - -Known limitations: - -- unrecognized migration paths still fall back to partial basis -- ambiguous transfer provenance still falls back to unknown basis -- families with no direct interaction but non-zero current shares are still surfaced as partial holdings, with current value prioritized over full historical transfer receipt pricing -- transfer-only families with zero current shares can still be omitted from the response -- missing metadata, PPS, or token prices can block full valuation for a family - -This is why the response exposes: - -- `status` -- `costBasisStatus` -- `isComplete` - -Those fields are part of the design, not an accident. - -## Practical Reading Guide - -If you are debugging one wallet: - -1. Check `summary.isComplete`. -2. Find vaults with `costBasisStatus: "partial"`. -3. Look at: - - `unknownCostBasisShares` - - `unknownCostBasisValueUsd` - - `windfallPnlUsd` - - `eventCounts` -4. Call `/api/holdings/pnl/drilldown` for the relevant vault family. -5. Use debug mode only when you also want server-side logs or transaction-specific log tables. - -## Code Map - -The main files are: - -- `api/lib/holdings/services/graphql.ts` - - fetches address-scoped and tx-scoped PnL events -- `api/lib/holdings/services/pnl.ts` - - orchestrates event loading, ledger construction, and response materialization -- `api/lib/holdings/services/pnlTypes.ts` - - shared PnL event, ledger, and response types -- `api/lib/holdings/services/pnlShared.ts` - - shared numeric and key helpers used across the PnL pipeline -- `api/lib/holdings/services/pnlValuation.ts` - - summary building, missing-metadata materialization, and unknown-mode valuation logic -- `api/lib/holdings/services/pnl.test.ts` - - core lot, staking, migration, and ledger behavior -- `api/lib/holdings/services/pnl.modes.test.ts` - - `strict`, `zero_basis`, and `windfall` behavior - -## Bottom Line - -The current PnL engine is featureful and intentionally conservative. - -It is good at not inventing certainty where the chain history is ambiguous. -It is not a perfectly complete reconstruction of every vault transfer path. - -When you read the output: - -- `strict` answers “what can we prove?” -- `zero_basis` answers “what if unknown transfers were free?” -- `windfall` answers “what is the same economic gain, but split into receipt-time value and later market PnL?” diff --git a/api/lib/holdings/README.md b/api/lib/holdings/README.md index e633ba321..b6a482050 100644 --- a/api/lib/holdings/README.md +++ b/api/lib/holdings/README.md @@ -1,251 +1,193 @@ # Holdings APIs -Calculates historical USD values and portfolio PnL for a user's Yearn vault positions. +Calculates historical holdings value, per-vault breakdowns, recent activity, and protocol-return history for Yearn vault positions. -## Architecture +## Runtime Shape ``` -┌─────────────────────────────────────────────────────────────────────────────┐ -│ Frontend │ -│ usePortfolioHistory() hook │ -│ │ │ -│ ▼ │ -│ GET /api/holdings/history|breakdown?address=0x... │ -└─────────────────────────────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────────────────┐ -│ Bun API Server (port 3001) │ -│ │ │ -│ ▼ │ -│ ┌─────────────────┐ │ -│ │ Aggregator │ ◄── Main orchestrator │ -│ └────────┬────────┘ │ -│ │ │ -│ ┌───────────────────┼───────────────────┐ │ -│ ▼ ▼ ▼ │ -│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ -│ │ Cache │ │ Services │ │ Holdings │ │ -│ │ (Postgres) │ │ │ │ Calculator │ │ -│ └─────────────┘ └──────┬──────┘ └─────────────┘ │ -│ │ │ -│ ┌─────────────┼─────────────┐ │ -│ ▼ ▼ ▼ │ -│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ -│ │ Envio │ │ Kong │ │ DefiLlama│ │ -│ │ GraphQL │ │ API │ │ API │ │ -│ └──────────┘ └──────────┘ └──────────┘ │ -└─────────────────────────────────────────────────────────────────────────────┘ +Frontend hooks + │ + ├─ GET /api/holdings/history + ├─ GET /api/holdings/progress + ├─ GET /api/holdings/breakdown + ├─ GET /api/holdings/activity + └─ GET /api/holdings/protocol-return/history + │ + ▼ +Holdings services + │ + ├─ Envio GraphQL: deposits, withdrawals, transfers + ├─ Kong: vault metadata and historical PPS + ├─ yearn-prices or DefiLlama: historical token prices + └─ PostgreSQL: optional server-side cache, progress, rate limits, invalidations ``` -## Data Flow +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. -### 1. Request Handling -``` -User Request → API Server → Aggregator → Response -``` +## Core Model -### 2. Data Collection - -``` -┌─────────────────────────────────────────────────────────────────┐ -│ For 365-day period: │ -│ │ -│ 1. Check Cache ──────────────────────────────────────────────► │ -│ │ │ -│ └─► If cached: Use cached daily totals │ -│ └─► If not cached: │ -│ │ -│ 2. Fetch Events from Envio ─────────────────────────────────► │ -│ • V3 Deposits (owner = user) │ -│ • V3 Withdrawals (owner = user) │ -│ • V2 Deposits (recipient = user) │ -│ • V2 Withdrawals (recipient = user) │ -│ • Transfers In (receiver = user) │ -│ • Transfers Out (sender = user) │ -│ │ -│ 3. Build Position Timeline ─────────────────────────────────► │ -│ • Calculate share balance at each point in time │ -│ │ -│ 4. For each vault position: │ -│ a. Fetch vault metadata (Kong) ─────────────────────────► │ -│ • Vault asset token address, decimals │ -│ │ -│ b. Fetch Price Per Share (Kong) ────────────────────────► │ -│ • Historical PPS timeseries │ -│ │ -│ c. Fetch Token Price (DefiLlama) ───────────────────────► │ -│ • Historical USD prices for the vault asset token │ -│ │ -│ 5. Calculate USD Value per day ────────────────────────────► │ -│ │ -│ 6. Save daily totals to Cache ─────────────────────────────► │ -└─────────────────────────────────────────────────────────────────┘ -``` - -## USD Value Calculation +### Holdings Value +```text +USD value = vault shares * price per share * vault asset USD price ``` -USD Value = Shares × Price Per Share × Underlying Token Price -Where: - • Shares = User's vault token balance (from events) - • PPS = Price Per Share at that timestamp (from Kong) - • Token Price = USD price of the vault asset token (from DefiLlama) -``` +- `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. -### Example: -``` -User has: 100 yvUSDC shares -PPS: 1.05 (vault has earned 5% yield) -USDC: $1.00 +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. -USD Value = 100 × 1.05 × 1.00 = $105.00 -``` +### Settled Daily History -For LP-based vaults, the "token price" can be an LP token price rather than a plain asset like USDC. For example, a Curve strategy vault may use the Curve LP token as its asset token, and the USD value is still computed as: +History endpoints return settled UTC days only. The latest point is the previous settled UTC day, not an intraday moving "today" point. -`vault shares × PPS × LP token USD price` +- `timeframe=1y`: last `365` settled UTC days. +- `timeframe=all`: supported range from `2024-01-01T00:00:00Z` through the latest settled UTC day. -## Components +The API internally values each day at `23:59:59 UTC`. -### Services +## Services | Service | Source | Purpose | |---------|--------|---------| -| `graphql.ts` | Envio Indexer | Fetch deposit/withdraw/transfer events with pagination | -| `kong.ts` | Kong API | Fetch historical Price Per Share timeseries | -| `vaults.ts` | Kong API | Fetch vault metadata (token info, decimals, staking) | -| `defillama.ts` | DefiLlama API | Fetch historical token prices | -| `cow.ts` | Ethereum RPC + CoW settlement logs | Synthesize known-basis acquisitions for recognized CoW settlement flows | -| `cache.ts` | PostgreSQL | Cache daily USD totals per user + positive token prices + exact price misses | -| `holdings.ts` | Local | Build position timeline, calculate share balances | -| `aggregator.ts` | Local | Orchestrate all services, main entry point | -| `pnl.ts` | Local | Build family ledgers, FIFO lots, and PnL summaries | -| `pnlSimple.ts` | Local | Build address-scoped protocol-return exposure ledgers without cost-basis reconstruction | - -### Data Types - -```typescript -// API Response (internal) -interface HoldingsHistoryResponse { - address: string - periodDays: number - dataPoints: Array<{ - date: string // "2024-01-15" - timestamp: number // Unix timestamp (end of the settled UTC day) - totalUsdValue: number - }> -} - -// Simplified response (from server) -{ - address: string - version: 'v2' | 'v3' | 'all' - dataPoints: Array<{ - date: string - value: number - }> -} -``` - -## Vault Versions - -The API supports both V2 and V3 Yearn vaults: - -| Version | Deposit Event | Withdraw Event | User Field | +| `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 | +| `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` | PostgreSQL | Daily totals, rate limits, and lazy vault invalidation | + +## 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` | -Events are normalized internally to a common format for timeline processing. +Transfers are also indexed to account for share movement not represented by direct deposits or withdrawals. -## Transfer Events +- 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. -Transfer events track vault shares moving between addresses (not through deposit/withdraw): +## Price Provider -- **Transfers In**: Shares received from another address -- **Transfers Out**: Shares sent to another address +`defillama.ts` is intentionally still named for compatibility, but it now selects between yearn-prices and DefiLlama. -### Special Cases +Provider selection: -1. **Mint filtering**: For vaults with indexed deposit events, transfers from `0x000...` (mints) are excluded (already tracked via Deposit events) -2. **Burn filtering**: For vaults with indexed withdraw events, transfers to `0x000...` (burns) are excluded (already tracked via Withdraw events) -3. **Transfer-only vaults**: Some vaults (e.g., staking contracts) don't have indexed Deposit events. For these, mint events ARE included to properly track deposits +- `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. -### Staking Vaults +yearn-prices behavior: -Staking vault positions are tracked via the `stakingToVaultMap` in `vaults.ts`. When a user deposits into a staking contract, the system maps the staking address to its underlying vault metadata for proper valuation. +- 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. -### PnL-Specific Event Enrichment +DefiLlama behavior: -The PnL endpoint fetches more context than the history endpoint: +- 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. -1. Address-scoped events for the user -2. Additional transaction-scoped events for the same transaction hashes +## Endpoints -That transaction-hash enrichment lets the PnL engine match same-transaction router flows, staking stake/unstake moves, and known migration rollovers even when the economically relevant event was not emitted directly on the user's address filter. +All public holdings routes support CORS, `GET`, and `OPTIONS`. When database caching is enabled, they rate-limit by forwarded IP, falling back to a simple header fingerprint. -For some recognized Ethereum mainnet CoW settlement transactions, the PnL path also inspects the transaction receipt and settlement logs to synthesize a deposit-like acquisition with known basis. This reduces false partial-cost-basis cases for routed buys into vault asset/share positions. +### `GET /api/holdings/history` -## API Endpoints +Daily holdings chart. -### GET `/api/holdings/history` -Holdings history for charts (date + total value). - -The history series ends at the latest settled UTC day, not an intraday "today" point. This keeps the daily series cacheable and avoids recomputing a moving final point on every request. For current portfolio value, use `/api/holdings/pnl`. -Vaults with `isHidden=true` in authoritative Kong metadata are excluded from history totals and do not contribute to chart points. +Examples: ```bash curl "http://localhost:3001/api/holdings/history?address=0x..." -curl "http://localhost:3001/api/holdings/history?address=0x...&fetchType=parallel" -curl "http://localhost:3001/api/holdings/history?address=0x...&paginationMode=all" +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: -- `address` (required): Ethereum address -- `version` (optional): `v2`, `v3`, or `all` (default: `all`) -- `refresh` (optional, local Bun server only): `true` or `1` to force cache refresh -- `fetchType` (optional): `seq` or `parallel` (default: `seq`) -- `paginationMode` (optional): `paged` or `all` (default: `paged`) + +| 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` | +| `refresh` | Local only | `false` | `true` or `1` clears the user's cached totals before computing | +| `debug`, `debugLots`, `debugVault`, `debugTx` | Local only | - | Debug logging controls in `api/server.ts` | Response: + ```json { "address": "0x...", "version": "all", + "denomination": "usd", + "timeframe": "1y", "dataPoints": [ - { "date": "2024-01-01", "value": 1000.50 }, - { "date": "2024-01-02", "value": 1005.25 } + { "date": "2026-05-05", "value": 1000.5 }, + { "date": "2026-05-06", "value": 1005.25 } ] } ``` -### GET `/api/holdings/breakdown` -Per-vault valuation breakdown for the latest settled holdings-history point. +Returns `404` when the wallet has no non-zero history points for the request. + +### `GET /api/holdings/breakdown` + +Per-vault valuation for a settled UTC date. Without `date`, it uses the latest settled holdings-history day. -This endpoint uses the same settled UTC day as the final point on `/api/holdings/history`, so it is useful for explaining why the latest chart value is what it is. It does not use current intraday balances or current intraday prices. -Vaults with `isHidden=true` in authoritative Kong metadata are excluded from the returned rows. +Examples: ```bash curl "http://localhost:3001/api/holdings/breakdown?address=0x..." -curl "http://localhost:3001/api/holdings/breakdown?address=0x...&version=v3" -curl "http://localhost:3001/api/holdings/breakdown?address=0x...&fetchType=parallel&paginationMode=all" +curl "http://localhost:3001/api/holdings/breakdown?address=0x...&date=2026-05-06" ``` Query params: -- `address` (required): Ethereum address -- `version` (optional): `v2`, `v3`, or `all` (default: `all`) -- `fetchType` (optional): `seq` or `parallel` (default: `seq`) -- `paginationMode` (optional): `paged` or `all` (default: `paged`) -Response (abridged): +| 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-04-07", - "timestamp": 1775529600, + "date": "2026-05-06", + "timestamp": 1778111999, "summary": { "totalVaults": 3, "vaultsWithShares": 2, @@ -279,128 +221,83 @@ Response (abridged): } ``` -### GET `/api/holdings/pnl` -FIFO-based realized and unrealized PnL for the user’s vault activity. +### `GET /api/holdings/activity` -For the dedicated accounting-model walkthrough, see [`PNL.md`](./PNL.md). -Vaults with `isHidden=true` in authoritative Kong metadata are excluded from both returned rows and summary totals. +Recent classified vault activity. ```bash -curl "http://localhost:3001/api/holdings/pnl?address=0x..." -curl "http://localhost:3001/api/holdings/pnl?address=0x...&unknownMode=windfall" -curl "http://localhost:3001/api/holdings/pnl?address=0x...&fetchType=parallel" -curl "http://localhost:3001/api/holdings/pnl?address=0x...&paginationMode=all" +curl "http://localhost:3001/api/holdings/activity?address=0x..." +curl "http://localhost:3001/api/holdings/activity?address=0x...&limit=20&offset=20" ``` Query params: -- `address` (required): Ethereum address -- `version` (optional): `v2`, `v3`, or `all` (default: `all`) -- `unknownMode` (optional): `strict`, `zero_basis`, or `windfall` (default: `windfall`) -- `fetchType` (optional): `seq` or `parallel` (default: `seq`) -- `paginationMode` (optional): `paged` or `all` (default: `paged`) -Response (abridged): +| 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 | + +Response: + ```json { "address": "0x...", "version": "all", - "unknownTransferInPnlMode": "windfall", - "generatedAt": "2026-03-16T12:00:00.000Z", - "summary": { - "totalVaults": 5, - "completeVaults": 4, - "partialVaults": 1, - "totalCurrentValueUsd": 1500.0, - "totalUnknownCostBasisValueUsd": 0, - "totalWindfallPnlUsd": 100.0, - "totalRealizedPnlUsd": 20.5, - "totalUnrealizedPnlUsd": 45.25, - "totalPnlUsd": 65.75, - "totalEconomicGainUsd": 165.75, - "byCategory": { - "stable": { - "totalPnlUsd": 40.0, - "totalEconomicGainUsd": 60.0 - }, - "volatile": { - "totalPnlUsd": 25.75, - "totalEconomicGainUsd": 105.75 - } - }, - "isComplete": false + "limit": 10, + "offset": 0, + "pageInfo": { + "hasMore": true, + "nextOffset": 10 }, - "vaults": [ + "entries": [ { "chainId": 1, + "txHash": "0x...", + "timestamp": 1778111999, + "action": "deposit", "vaultAddress": "0x...", - "status": "ok", - "costBasisStatus": "partial", - "unknownTransferInPnlMode": "windfall", - "currentUnderlying": 105.0, - "knownCostBasisUnderlying": 100.0, - "knownCostBasisUsd": 100.0, - "unknownCostBasisValueUsd": 0, - "windfallPnlUsd": 100.0, - "realizedPnlUsd": 12.5, - "unrealizedPnlUsd": 4.25, - "totalPnlUsd": 16.75, - "totalEconomicGainUsd": 116.75, - "currentValueUsd": 105.0, - "metadata": { - "symbol": "USDC", - "decimals": 6, - "assetDecimals": 6, - "tokenAddress": "0x...", - "category": "stable" - } + "familyVaultAddress": "0x...", + "assetSymbol": "USDC", + "assetAmount": "1000000", + "assetAmountFormatted": 1, + "shareAmount": "1000000", + "shareAmountFormatted": 1, + "status": "ok" } ] } ``` -The live response also includes: - -- share balances -- vault-share vs staked-share splits -- per-vault event counts -- explicit underlying amounts (`currentUnderlying`, `vaultUnderlying`, `stakedUnderlying`) -- explicit known-basis metrics (`knownCostBasisUnderlying`, `knownCostBasisUsd`, `currentKnownUnderlying`, `currentUnknownUnderlying`) - -Notes: -- Deposits create FIFO lots using the indexed `assets` and `shares` values. -- Known-basis USD PnL is true mark-to-market PnL: deposit lots keep their acquisition timestamp, and USD basis is valued using the underlying token price at deposit time. -- Withdrawals realize PnL from the oldest remaining lots first. -- `totalPnlUsd = totalRealizedPnlUsd + totalUnrealizedPnlUsd`. -- `totalEconomicGainUsd = totalPnlUsd + totalWindfallPnlUsd`. -- Some recognized reward-distribution flows are treated as explicit zero-basis reward receipts rather than unknown transfers. Those shares are considered economically free, but they stay `costBasisStatus: "complete"` and contribute to normal realized / unrealized PnL instead of `windfallPnlUsd`. -- `summary.byCategory.stable` and `summary.byCategory.volatile` split `totalPnlUsd` and `totalEconomicGainUsd` by Kong vault category while the top-level summary remains the portfolio-wide total. -- Staking wrappers are collapsed into the underlying vault family. Staked shares and directly held vault shares share the same FIFO lots and only change location. -- Underlying vault `Deposit` and `Withdraw` events define cost basis and realized proceeds. Staking `Deposit` and `Withdraw` events are treated as stake or unstake moves, not as economic entries. -- Same-transaction router flows can carry basis into or out of a staking vault family when the transfer can be matched to an indexed underlying vault deposit or withdrawal in the same transaction. -- Recognized vault-to-vault rollover transactions can roll basis from a source family into a destination family, including known migrator flows, supported Enso-mediated rollovers on Ethereum mainnet, and supported compatible nested-vault paths where one Yearn vault token is the asset of another Yearn vault. The current compatible nested-vault allowlist includes the `ysyBOLD <-> yBOLD` path on Ethereum mainnet. When source basis cannot be reconstructed, the destination shares stay partial / unknown-basis. -- Plain share transfers may leave some lots with unknown cost basis. Those vaults are returned with `costBasisStatus: "partial"`. In `strict` mode, the unmatched current portion is reported in `unknownCostBasisValueUsd`; in `zero_basis` and `windfall`, that value is zeroed and the economics are attributed according to `unknownTransferInPnlMode`. -- The endpoint keeps families with non-zero current shares even if they only arrived through transfers, but transfer-only families with zero remaining shares may still be omitted. Those transfer-only holdings are marked partial and prioritized for current-value completeness over full historical price reconstruction. -- `isComplete` becomes `false` when at least one returned vault still has partial / unknown basis. - -### GET `/api/holdings/pnl/simple` -Protocol-return summary for the user’s vault exposure. - -This route is intentionally not a cost-basis PnL engine. It measures how much Yearn increased the user’s withdrawable underlying amount while the user held vault shares. Receipt-time token prices are used only to weight different assets into one portfolio percentage. - -It uses only address-scoped events, so it avoids transaction-hash enrichment, CoW receipt enrichment, and FIFO cost-basis classification. This makes it cheaper than `/api/holdings/pnl`, but it should be labeled as protocol return rather than accounting PnL. -Like `/api/holdings/pnl`, it excludes vaults with `isHidden=true` in authoritative Kong metadata. +Activity classification merges address-scoped events with transaction-scoped context, so router-mediated staking, unstaking, deposit, and withdraw flows can be represented as user actions. + +### `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/pnl/simple?address=0x..." -curl "http://localhost:3001/api/holdings/pnl/simple?address=0x...&version=v3" -curl "http://localhost:3001/api/holdings/pnl/simple?address=0x...&fetchType=parallel" +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: -- `address` (required): Ethereum address -- `version` (optional): `v2`, `v3`, or `all` (default: `all`) -- `fetchType` (optional): `seq` or `parallel` (default: `seq`) -- `paginationMode` (optional): `paged` or `all` (default: `paged`) + +| 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` | +| `debug`, `debugLots`, `debugVault`, `debugTx` | Local only | - | Debug logging controls in `api/server.ts` | Metric model: @@ -412,291 +309,208 @@ growthWeightUsd = growthUnderlying * receiptTokenPriceUsd protocolReturnPct = growthWeightUsd / baselineWeightUsd * 100 ``` -Because both numerator and denominator use the same receipt-time token price, later asset price movement does not affect `protocolReturnPct`. +Because numerator and denominator use the same receipt-time token price, later asset price movement does not affect `protocolReturnPct`. -Response (abridged): +Response: ```json { "address": "0x...", "version": "all", + "timeframe": "1y", + "generatedAt": "2026-05-07T00:00:00.000Z", "summary": { "totalVaults": 5, "completeVaults": 5, "partialVaults": 0, - "baselineWeightUsd": 7000, - "growthWeightUsd": 700, - "protocolReturnPct": 10, + "recommendedGrowthDisplay": "index", + "recommendedGrowthDisplayReason": "mixed", + "openBaselineCompositionUsd": { + "stable": 100, + "ethFamily": 50, + "other": 0 + }, "isComplete": true }, - "vaults": [ + "dataPoints": [ { - "chainId": 1, - "vaultAddress": "0x...", - "status": "ok", - "baselineUnderlying": 1000, - "growthUnderlying": 100, - "baselineWeightUsd": 1000, + "date": "2026-05-06", + "timestamp": 1778111999, "growthWeightUsd": 100, + "growthWeightEth": null, "protocolReturnPct": 10, - "receiptCount": 1, - "exitCount": 0 + "annualizedProtocolReturnPct": 12, + "growthIndex": 110 } - ] + ], + "familySeries": [] } ``` -### GET `/api/holdings/pnl/drilldown` -Lot-level drilldown for the PnL engine. This is the "excessive" companion to `/api/holdings/pnl`. +When a vault filter is present, each history point can also include `currentUnderlying`, `growthUnderlying`, `sharesFormatted`, and `pricePerShare`. + +### `POST /api/admin/invalidate-cache` -Use it when the frontend needs current lots, realized lot consumption, unknown-basis receipts / withdrawals, or a transaction journal showing how the family lot state changed over time. -Like `/api/holdings/pnl`, it excludes vaults with `isHidden=true` in authoritative Kong metadata. +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 "http://localhost:3001/api/holdings/pnl/drilldown?address=0x..." -curl "http://localhost:3001/api/holdings/pnl/drilldown?address=0x...&vault=0x..." -curl "http://localhost:3001/api/holdings/pnl/drilldown?address=0x...&unknownMode=windfall" +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" ``` -Query params: -- `address` (required): Ethereum address -- `vault` (optional): family vault or staking vault address to limit the response to one family -- `version` (optional): `v2`, `v3`, or `all` (default: `all`) -- `unknownMode` (optional): `strict`, `zero_basis`, or `windfall` (default: `windfall`) -- `fetchType` (optional): `seq` or `parallel` (default: `seq`) -- `paginationMode` (optional): `paged` or `all` (default: `paged`) - -Response additions per vault: -- `currentLots.vault` and `currentLots.staked` -- `realizedEntries` with consumed lots and USD/underlying proceeds/basis/PnL -- `rewardTransferInEntries` for recognized zero-basis reward receipts -- `unknownTransferInEntries` with receipt-time valuation -- `unknownWithdrawalEntries` with proceeds and consumed unknown lots -- `journal` rows with before/after lot summaries for vault-share and staked-share locations - -Notes: -- `rewardTransferInEntries` are known-basis receipts with `costBasis = 0`, so they are not affected by `unknownMode`. -- The drilldown journal includes `rewardInVaultShares` / `rewardInStakedShares` and the transaction hash. If the UI wants explorer links for reward rows, it should join the reward entry to the matching journal row. - -The drilldown response uses the same summary fields and top-level identity fields as `/api/holdings/pnl`, but each vault row is expanded for UI drilldown rather than table rendering. - -### Unknown Transfer-In Modes - -Unknown share transfers can be interpreted three ways: - -- `strict` - - Unknown shares do not contribute to PnL. - - Their current value is reported in `unknownCostBasisValueUsd`. -- `zero_basis` - - Unknown shares are treated as if they were acquired for zero cost. - - Receipt-time value and any later market move both end up inside realized / unrealized PnL. -- `windfall` (default) - - Unknown shares are still treated as free economically, but the gain is split into two parts. - - Receipt-time fair value is isolated in `windfallPnlUsd`. - - `totalPnlUsd` only tracks market movement after receipt. - - `totalEconomicGainUsd = totalPnlUsd + totalWindfallPnlUsd`. - -`zero_basis` and `windfall` can report the same `totalEconomicGainUsd`. The difference is attribution: - -- `zero_basis` books the full gain as market PnL. -- `windfall` books receipt-time value as windfall and only the post-receipt move as market PnL. - -Example: - -```text -Unknown shares received at $1,000 value and later worth $1,150 - -strict: - totalPnlUsd = 0 - unknownCostBasisValueUsd = 1,150 - -zero_basis: - totalPnlUsd = 1,150 - totalEconomicGainUsd = 1,150 +Response: -windfall: - totalWindfallPnlUsd = 1,000 - totalPnlUsd = 150 - totalEconomicGainUsd = 1,150 +```json +{ + "success": true, + "invalidated": 1, + "vaults": ["1:0x..."], + "timestamp": "2026-05-07T00:00:00.000Z" +} ``` ## Supported Chains -| Chain | ID | DefiLlama Prefix | -|-------|-----|-----------------| -| Ethereum | 1 | ethereum | -| Base | 8453 | base | -| Arbitrum | 42161 | arbitrum | -| Polygon | 137 | polygon | +| 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 | `''` (empty) | Envio Hasura admin secret (header skipped if empty or 'testing') | -| `DATABASE_URL_PREVIEW` | No | `null` | Preview PostgreSQL connection string. Used in preference to `DATABASE_URL` when set. | -| `DATABASE_URL` | No | `null` | Default PostgreSQL connection string (caching disabled if neither DB env var is set) | -| `ETHEREUM_RPC_URL` | No | `https://ethereum-rpc.publicnode.com` | Mainnet RPC used for receipt-level enrichment such as CoW settlement decoding | -| `DEFILLAMA_API_KEY` | No | `''` (empty) | Enables the paid DefiLlama GET route at `https://pro-api.llama.fi/{key}/coins/batchHistorical?coins=...` | -| `ADMIN_SECRET` | No | `null` | Secret for admin endpoints (cache invalidation). Use 32+ char random string. | - -**Note**: Kong and DefiLlama base URLs are hardcoded: +| `ENVIO_PASSWORD` | No | `''` | Envio Hasura admin secret; skipped when empty or `testing` | +| `DATABASE_URL_PREVIEW` | No | `null` | Preview PostgreSQL URL, preferred over `DATABASE_URL` when set | +| `DATABASE_URL` | No | `null` | Default PostgreSQL URL; caching and rate-limit persistence are disabled when absent | +| `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` -## Pagination & Performance - -### The 1000 Result Limit +## Event Fetching -Envio's hosted service enforces a **hard limit of 1000 results per query**. This is server-side and cannot be increased by requesting a larger `limit` parameter. Users with extensive transaction history require pagination to fetch all events. +Envio hosted GraphQL has a practical `1000`-row page limit. Holdings routes expose controls for fetching address-scoped event families: -### Current Solution: Sequential Pagination +- `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. -Each event type is fetched with sequential pagination - pages are fetched one after another until fewer than `BATCH_SIZE` (1000) results are returned: +`parallel` depends on aggregate roots being available: -``` -Query: User has 3,500 transfers - │ - ├─► Fetch offset=0, limit=1000 → 1000 results (continue) - ├─► Fetch offset=1000, limit=1000 → 1000 results (continue) - ├─► Fetch offset=2000, limit=1000 → 1000 results (continue) - └─► Fetch offset=3000, limit=1000 → 500 results (done!) -``` - -All 6 event types (V3 deposits/withdrawals, V2 deposits/withdrawals, transfers in/out) are fetched in parallel, but each type paginates sequentially within itself. - -### PnL Fetch Controls - -The PnL endpoint exposes request-time controls for benchmarking alternate address-scoped fetch strategies: - -- `fetchType=seq|parallel` - - `seq`: normal `1000`-row sequential pagination - - `parallel`: try GraphQL aggregate counts first, then fetch address-scoped pages concurrently - -- `paginationMode=paged|all` - - `paged`: normal `limit/offset` pagination - - `all`: issue one large query per event family instead of paging - -`parallel` depends on these aggregate roots being available on the GraphQL schema: - `Deposit_aggregate`, `Withdraw_aggregate` - `V2Deposit_aggregate`, `V2Withdraw_aggregate` - `Transfer_aggregate` -If aggregate roots are unavailable in a given environment, the code falls back to sequential pagination. `paginationMode=all` bypasses the aggregate preflight entirely and is meant for experimentation rather than as a production default. +If aggregates are unavailable, the code falls back to sequential pagination. For most production traffic, `fetchType=parallel&paginationMode=paged` is the preferred fast path. -### maxTimestamp Optimization +## Caching -When only calculating a few missing days, the `maxTimestamp` parameter limits event fetching: +Server-side cache is optional. When `DATABASE_URL_PREVIEW` or `DATABASE_URL` is absent, the APIs still work but recompute history and refetch prices/PPS on each request. -```typescript -// Only fetch events up to end of last missing day -const maxTimestamp = Math.max(...missingTimestamps) + 86400 -const events = await fetchUserEvents(userAddress, 'all', maxTimestamp, fetchType, paginationMode) -``` +### Cache Layers -## Caching Strategy +1. PostgreSQL: + - `holdings_totals`: daily USD totals per hashed user address, vault version, and date. + - `rate_limits`: simple per-client request windows, cleaned opportunistically after the active window expires. + - `vault_invalidations`: per-vault invalidation timestamps for lazy cache clearing. + - `holdings_progress`: authoritative short-lived progress records keyed by hashed wallet identity 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`. +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. -The cache stores **daily USD totals per user** (not per-vault breakdowns). +### Daily Totals -``` -┌─────────────────────────────────────────────────────────────┐ -│ Cache Lookup Flow │ -│ │ -│ Request for 365 days of data │ -│ │ │ -│ ▼ │ -│ ┌─────────────────┐ │ -│ │ Check DB cache │ │ -│ │ for date range │ │ -│ └────────┬────────┘ │ -│ │ │ -│ ▼ │ -│ ┌──────────────────────────────────────────────┐ │ -│ │ If all settled UTC days are cached │ │ -│ │ → return directly from cache │ │ -│ └──────────────────────────────────────────────┘ │ -│ ┌──────────────────────────────────────────────┐ │ -│ │ If some settled days are missing │ │ -│ │ → fetch only up to the last missing day │ │ -│ │ → calculate missing days │ │ -│ │ → save new daily totals to cache │ │ -│ └──────────────────────────────────────────────┘ │ -└─────────────────────────────────────────────────────────────┘ -``` +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. -The history series ends at the latest settled UTC day rather than an intraday moving "today" point. Current portfolio value should come from `/api/holdings/pnl`. +Cache behavior: -### Cache Layers +- 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 `vault_invalidations` 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. -1. **PostgreSQL** (server): - - Daily totals cached per user and vault version - - Token prices cached globally (shared across all users) - - Exact token/timestamp price misses cached globally with TTL to suppress repeated unsupported DefiLlama fetches -2. **HTTP Cache-Control** (CDN): `s-maxage=300, stale-while-revalidate=600` -3. **TanStack Query** (client): 4-hour cache duration +### Token Prices + +Token prices are fetched from the selected provider for each request. The holdings DB does not cache positive token prices or price misses. ## Database Schema ```sql --- User daily totals (one row per user per day) CREATE TABLE IF NOT EXISTS holdings_totals ( user_address_hash VARCHAR(64) NOT NULL, - version VARCHAR(8) NOT NULL DEFAULT 'all', - date DATE NOT NULL, - usd_value NUMERIC NOT NULL, - updated_at TIMESTAMP DEFAULT NOW(), + version VARCHAR(8) NOT NULL DEFAULT 'all', + date DATE NOT NULL, + usd_value NUMERIC NOT NULL, + updated_at TIMESTAMP DEFAULT NOW(), PRIMARY KEY (user_address_hash, version, date) ); --- Token price cache (shared across all users) -CREATE TABLE IF NOT EXISTS token_prices ( - token_key VARCHAR(100) NOT NULL, -- e.g., "ethereum:0xa0b8..." - timestamp INTEGER NOT NULL, -- Unix timestamp (end of the settled UTC day) - price NUMERIC NOT NULL, - created_at TIMESTAMP DEFAULT NOW(), - PRIMARY KEY (token_key, timestamp) -); - --- Exact token/timestamp misses for unsupported DefiLlama points -CREATE TABLE IF NOT EXISTS token_price_misses ( - token_key VARCHAR(100) NOT NULL, - timestamp INTEGER NOT NULL, - expires_at TIMESTAMP NOT NULL, - created_at TIMESTAMP DEFAULT NOW(), - PRIMARY KEY (token_key, timestamp) +CREATE TABLE IF NOT EXISTS rate_limits ( + ip VARCHAR(45) PRIMARY KEY, + request_count INTEGER DEFAULT 1, + window_start TIMESTAMP DEFAULT NOW() ); --- Vault invalidation timestamps (for cache invalidation) CREATE TABLE IF NOT EXISTS vault_invalidations ( - vault_address VARCHAR(42) NOT NULL, - chain_id INTEGER NOT NULL, + vault_address VARCHAR(42) NOT NULL, + chain_id INTEGER NOT NULL, invalidated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), PRIMARY KEY (vault_address, chain_id) ); -CREATE INDEX IF NOT EXISTS idx_token_prices_token_key ON token_prices(token_key); -CREATE INDEX IF NOT EXISTS idx_token_price_misses_token_key ON token_price_misses(token_key); -CREATE INDEX IF NOT EXISTS idx_token_price_misses_expires_at ON token_price_misses(expires_at); +CREATE TABLE IF NOT EXISTS holdings_progress ( + id VARCHAR(160) PRIMARY KEY, + route VARCHAR(64) NOT NULL, + address_hash VARCHAR(64) NOT NULL, + status VARCHAR(16) NOT NULL, + progress INTEGER NOT NULL, + message TEXT NOT NULL, + detail TEXT, + started_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + logs JSONB NOT NULL DEFAULT '[]'::jsonb +); + +CREATE INDEX IF NOT EXISTS idx_rate_limits_window ON rate_limits(window_start); CREATE INDEX IF NOT EXISTS idx_vault_invalidations_time ON vault_invalidations(invalidated_at); +CREATE INDEX IF NOT EXISTS idx_holdings_progress_updated_at ON holdings_progress(updated_at); ``` -- `holdings_totals`: ~365 rows per hashed user key for full history -- `token_prices`: Shared positive-price cache, reduces DefiLlama API calls for exact timestamp hits -- `token_price_misses`: Shared negative cache for exact unsupported price points, currently stored with a 7-day TTL -- `vault_invalidations`: Tracks when vaults were invalidated for lazy cache refresh - -## Cache Invalidation - -When new vaults are added to the indexer, cached data becomes stale. The system uses **lazy per-vault invalidation**: +Legacy `holdings_totals.user_address` rows are migrated to `user_address_hash`, and the primary key is migrated to `(user_address_hash, version, date)`. -1. After deploying indexer with new vault, call `POST /api/admin/invalidate-cache` with the vault addresses -2. This records invalidation timestamps in `vault_invalidations` table -3. On user requests, the system checks if any of the user's vaults were invalidated after their cache was written -4. If stale, the user's cache is cleared and recalculated +## Operational Notes -This approach: -- Only refreshes cache for users who hold the newly-indexed vault -- Refresh happens lazily on next request (after client cache expires) -- No need to know affected users upfront +- Enable DB caching 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. +- Use `/api/admin/invalidate-cache` after indexer deployments add or repair vault coverage. +- Stale rate-limit rows are cleaned opportunistically when holdings rate checks run. +- Short-lived progress rows are cleaned opportunistically when progress-enabled holdings requests run; if DB 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 index bf1921ac4..e1bef43b7 100644 --- a/api/lib/holdings/config.ts +++ b/api/lib/holdings/config.ts @@ -2,15 +2,20 @@ export interface HoldingsConfig { readonly envioGraphqlUrl: string readonly envioPassword: string readonly databaseUrl: string | null - readonly ethereumRpcUrl: string readonly kongBaseUrl: string + readonly yearnPricesBaseUrl: string + readonly yearnPricesApiKey: string readonly defillamaBaseUrl: string readonly defillamaProBaseUrl: string readonly defillamaApiKey: string readonly historyDays: number + readonly historyStartTimestamp: number } -export const config: HoldingsConfig = { +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' }, @@ -20,16 +25,22 @@ export const config: HoldingsConfig = { get databaseUrl() { return process.env.DATABASE_URL_PREVIEW ?? process.env.DATABASE_URL ?? null }, - get ethereumRpcUrl() { - return process.env.ETHEREUM_RPC_URL ?? 'https://ethereum-rpc.publicnode.com' - }, 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 + historyDays: 365, + historyStartTimestamp: HISTORY_START_TIMESTAMP } export function validateConfig(): void { @@ -39,7 +50,4 @@ export function validateConfig(): void { if (!process.env.DATABASE_URL_PREVIEW && !process.env.DATABASE_URL) { console.warn('[Holdings] DATABASE_URL_PREVIEW / DATABASE_URL not set, caching disabled') } - if (!process.env.ETHEREUM_RPC_URL) { - console.warn('[Holdings] ETHEREUM_RPC_URL not set, using default public mainnet RPC') - } } diff --git a/api/lib/holdings/db/connection.ts b/api/lib/holdings/db/connection.ts index 4889c2595..a4a2ae585 100644 --- a/api/lib/holdings/db/connection.ts +++ b/api/lib/holdings/db/connection.ts @@ -1,6 +1,10 @@ import { createHash } from 'node:crypto' import { Pool } from '@neondatabase/serverless' -import { config } from '../config' +import { holdingsConfig } from '../config' + +type NeonPoolWithFetchMode = typeof Pool & { + poolQueryViaFetch?: boolean +} interface QueryResult { rows: T[] @@ -8,7 +12,11 @@ interface QueryResult { } interface DatabasePool { - query: >(text: string, params?: unknown[]) => Promise> + query: >( + text: string, + params?: unknown[], + options?: { disableOnFailure?: boolean } + ) => Promise> end: () => Promise } @@ -21,7 +29,8 @@ let pool: DatabasePool | null = null let schemaInitializationPromise: Promise | null = null let databaseDisabled = false -const DB_QUERY_TIMEOUT_MS = 5_000 +const DB_QUERY_TIMEOUT_MS = 20_000 +const LOCAL_DB_QUERY_TIMEOUT_CODE = 'HOLDINGS_DB_QUERY_TIMEOUT' const RETRYABLE_CONNECTION_ERROR_CODES = new Set([ 'ECONNRESET', 'ECONNREFUSED', @@ -54,7 +63,9 @@ function toUserAddressHash(userAddress: string): string { function withTimeout(promise: Promise, timeoutMs: number, label: string): Promise { return new Promise((resolve, reject) => { const timeoutId = setTimeout(() => { - reject(new Error(`${label} timed out after ${timeoutMs}ms`)) + const error = new Error(`${label} timed out after ${timeoutMs}ms`) as DatabaseQueryError + error.code = LOCAL_DB_QUERY_TIMEOUT_CODE + reject(error) }, timeoutMs) promise.then( @@ -95,6 +106,10 @@ export function shouldDisableDatabaseOnQueryError(error: unknown): boolean { ? error.message.toLowerCase() : String(error).toLowerCase() + if (code === LOCAL_DB_QUERY_TIMEOUT_CODE) { + return false + } + if (code !== null && (RETRYABLE_CONNECTION_ERROR_CODES.has(code) || code.startsWith('08'))) { return true } @@ -116,21 +131,21 @@ export function shouldDisableDatabaseOnQueryError(error: unknown): boolean { } async function createPool(): Promise { - if (!config.databaseUrl || databaseDisabled) { + if (!holdingsConfig.databaseUrl || databaseDisabled) { return null } try { - Pool.poolQueryViaFetch = true - const neonPool = new Pool({ connectionString: config.databaseUrl }) + ;(Pool as NeonPoolWithFetchMode).poolQueryViaFetch = true + const neonPool = new Pool({ connectionString: holdingsConfig.databaseUrl }) return { - query: async (text: string, params?: unknown[]) => { + query: async (text: string, params?: unknown[], options?: { disableOnFailure?: boolean }) => { try { const result = await withTimeout(neonPool.query(text, params), DB_QUERY_TIMEOUT_MS, 'Holdings DB query') return { rows: result.rows as T[], rowCount: result.rowCount ?? 0 } } catch (error) { - if (shouldDisableDatabaseOnQueryError(error)) { + if (options?.disableOnFailure !== false && shouldDisableDatabaseOnQueryError(error)) { disableDatabase('query failure', error) } throw error @@ -149,7 +164,7 @@ export async function getPool(): Promise { return null } - if (pool === null && config.databaseUrl) { + if (pool === null && holdingsConfig.databaseUrl) { pool = await createPool() } return pool @@ -178,22 +193,6 @@ export async function initializeSchema(): Promise { ALTER TABLE holdings_totals ALTER COLUMN version SET DEFAULT 'all'; ALTER TABLE holdings_totals ALTER COLUMN version SET NOT NULL; - CREATE TABLE IF NOT EXISTS token_prices ( - token_key VARCHAR(100) NOT NULL, - timestamp INTEGER NOT NULL, - price NUMERIC NOT NULL, - created_at TIMESTAMP DEFAULT NOW(), - PRIMARY KEY (token_key, timestamp) - ); - - CREATE TABLE IF NOT EXISTS token_price_misses ( - token_key VARCHAR(100) NOT NULL, - timestamp INTEGER NOT NULL, - expires_at TIMESTAMP NOT NULL, - created_at TIMESTAMP DEFAULT NOW(), - PRIMARY KEY (token_key, timestamp) - ); - CREATE TABLE IF NOT EXISTS rate_limits ( ip VARCHAR(45) PRIMARY KEY, request_count INTEGER DEFAULT 1, @@ -207,11 +206,27 @@ export async function initializeSchema(): Promise { PRIMARY KEY (vault_address, chain_id) ); - CREATE INDEX IF NOT EXISTS idx_token_prices_token_key ON token_prices(token_key); - CREATE INDEX IF NOT EXISTS idx_token_price_misses_token_key ON token_price_misses(token_key); - CREATE INDEX IF NOT EXISTS idx_token_price_misses_expires_at ON token_price_misses(expires_at); + CREATE TABLE IF NOT EXISTS holdings_progress ( + id VARCHAR(160) PRIMARY KEY, + route VARCHAR(64) NOT NULL, + address_hash VARCHAR(64) NOT NULL, + status VARCHAR(16) NOT NULL, + progress INTEGER NOT NULL, + message TEXT NOT NULL, + detail TEXT, + started_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + logs JSONB NOT NULL DEFAULT '[]'::jsonb + ); + + ALTER TABLE holdings_progress ADD COLUMN IF NOT EXISTS address_hash VARCHAR(64); + DELETE FROM holdings_progress WHERE address_hash IS NULL; + ALTER TABLE holdings_progress ALTER COLUMN address_hash SET NOT NULL; + ALTER TABLE holdings_progress DROP COLUMN IF EXISTS address; + CREATE INDEX IF NOT EXISTS idx_rate_limits_window ON rate_limits(window_start); CREATE INDEX IF NOT EXISTS idx_vault_invalidations_time ON vault_invalidations(invalidated_at); + CREATE INDEX IF NOT EXISTS idx_holdings_progress_updated_at ON holdings_progress(updated_at); ` try { @@ -323,5 +338,5 @@ async function migrateHoldingsTotalsAddressStorage(db: DatabasePool): Promise ({ fetchRecentAddressScopedActivityEvents: fetchRecentAddressScopedActivityEventsMock, - fetchActivityEventsByTransactionHashes: fetchActivityEventsByTransactionHashesMock + fetchActivityEventsByTransactionHashes: fetchActivityEventsByTransactionHashesMock, + fetchUserEvents: fetchUserEventsMock })) vi.mock('./vaults', () => ({ @@ -15,10 +25,57 @@ vi.mock('./vaults', () => ({ 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 @@ -28,18 +85,20 @@ function createDepositEvent(args: { logIndex: number assets: string shares?: string + sender?: string + chainId?: number }) { return { id: args.id, vaultAddress: args.vaultAddress, - chainId: 1, + chainId: args.chainId ?? 1, blockNumber: args.blockTimestamp, blockTimestamp: args.blockTimestamp, logIndex: args.logIndex, transactionHash: args.transactionHash, transactionFrom: USER_ADDRESS, owner: USER_ADDRESS, - sender: USER_ADDRESS, + sender: args.sender ?? USER_ADDRESS, assets: args.assets, shares: args.shares ?? args.assets } @@ -53,11 +112,12 @@ function createWithdrawalEvent(args: { logIndex: number assets: string shares?: string + chainId?: number }) { return { id: args.id, vaultAddress: args.vaultAddress, - chainId: 1, + chainId: args.chainId ?? 1, blockNumber: args.blockTimestamp, blockTimestamp: args.blockTimestamp, logIndex: args.logIndex, @@ -78,11 +138,12 @@ function createTransferEvent(args: { value: string sender: string receiver: string + chainId?: number }) { return { id: args.id, vaultAddress: args.vaultAddress, - chainId: 1, + chainId: args.chainId ?? 1, blockNumber: args.blockTimestamp, blockTimestamp: args.blockTimestamp, logIndex: args.logIndex, @@ -94,15 +155,446 @@ function createTransferEvent(args: { } } +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 () => { @@ -222,11 +714,20 @@ describe('getHoldingsActivity', () => { 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' @@ -236,11 +737,20 @@ describe('getHoldingsActivity', () => { 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' @@ -250,11 +760,20 @@ describe('getHoldingsActivity', () => { 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' @@ -266,93 +785,28 @@ describe('getHoldingsActivity', () => { }) }) - 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', - vaultAddress: UNKNOWN_VAULT, - familyVaultAddress: UNKNOWN_VAULT, - assetSymbol: null, - assetAmount: '123456789', - assetAmountFormatted: 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 () => { + it('enriches router-mediated deposits with the user input token from the tx receipt', 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', + id: 'enso-deposit', vaultAddress: UNDERLYING_VAULT, - transactionHash: '0xccc', - blockTimestamp: 280, + transactionHash: '0xenso', + blockTimestamp: 230, logIndex: 2, - assets: '3000000', - shares: '3000000000000000000' + assets: '230118', + shares: '202094', + sender: INTERMEDIARY }) ], withdrawals: [], transfersIn: [], transfersOut: [], - hasMoreDeposits: true, + hasMoreDeposits: false, hasMoreWithdrawals: false, hasMoreTransfersIn: false, hasMoreTransfersOut: false }) - fetchMultipleVaultsMetadataMock.mockResolvedValue( new Map([ [ @@ -367,51 +821,71 @@ describe('getHoldingsActivity', () => { symbol: 'USDC', decimals: 6 }, - decimals: 18 + decimals: 6 } ] ]) ) + mockReceiptEnrichmentRpc({ + tokenAddress: USDT0, + tokenSymbol: 'USDT0', + tokenDecimals: 6 + }) const { getHoldingsActivity } = await import('./activity') - const response = await getHoldingsActivity(USER_ADDRESS, 'all', 1, 1) + const response = await getHoldingsActivity(USER_ADDRESS, 'all', 10) - expect(fetchRecentAddressScopedActivityEventsMock).toHaveBeenCalledWith(USER_ADDRESS, 'all', 20) expect(response.entries).toEqual([ { chainId: 1, - txHash: '0xbbb', - timestamp: 290, + txHash: '0xenso', + timestamp: 230, action: 'deposit', + transferDirection: null, vaultAddress: UNDERLYING_VAULT, familyVaultAddress: UNDERLYING_VAULT, assetSymbol: 'USDC', - assetAmount: '2000000', - assetAmountFormatted: 2, - shareAmount: '2000000000000000000', - shareAmountFormatted: 2, + 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' } ]) - expect(response.pageInfo).toEqual({ - hasMore: true, - nextOffset: 2 - }) }) - it('recovers routed withdrawals from address transfers plus tx-scoped withdraw events', async () => { + it('collapses router-mediated vault exits and entries in the same transaction into a swap row', async () => { fetchRecentAddressScopedActivityEventsMock.mockResolvedValue({ deposits: [], withdrawals: [], - transfersIn: [], + 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: 'transfer-out-1', + id: 'enso-swap-transfer-out', vaultAddress: UNDERLYING_VAULT, - transactionHash: '0xroute', - blockTimestamp: 400, + transactionHash: '0xensoswap', + blockTimestamp: 240, logIndex: 1, - value: '849068037733633594470', + value: '27000000000000000000', sender: USER_ADDRESS, receiver: INTERMEDIARY }) @@ -422,28 +896,49 @@ describe('getHoldingsActivity', () => { hasMoreTransfersOut: false }) fetchActivityEventsByTransactionHashesMock.mockResolvedValue({ - deposits: [], + deposits: [ + createDepositEvent({ + id: 'enso-swap-deposit', + vaultAddress: DESTINATION_VAULT, + transactionHash: '0xensoswap', + blockTimestamp: 240, + logIndex: 7, + assets: '39000000000000000000', + shares: '37000000000000000000', + sender: INTERMEDIARY + }) + ], withdrawals: [ createWithdrawalEvent({ - id: 'withdraw-route-1', + id: 'enso-swap-withdraw', vaultAddress: UNDERLYING_VAULT, - transactionHash: '0xroute', - blockTimestamp: 400, - logIndex: 5, - assets: '1072609', - shares: '849068037733633594470' + transactionHash: '0xensoswap', + blockTimestamp: 240, + logIndex: 2, + assets: '28000000000000000000', + shares: '27000000000000000000' }) ], transfers: [ createTransferEvent({ - id: 'transfer-burn-1', - vaultAddress: UNDERLYING_VAULT, - transactionHash: '0xroute', - blockTimestamp: 400, - logIndex: 4, - value: '849068037733633594470', + id: 'enso-swap-transfer-in', + vaultAddress: DESTINATION_VAULT, + transactionHash: '0xensoswap', + blockTimestamp: 240, + logIndex: 8, + value: '37000000000000000000', sender: INTERMEDIARY, - receiver: '0x0000000000000000000000000000000000000000' + 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 }) ] }) @@ -463,25 +958,1866 @@ describe('getHoldingsActivity', () => { }, 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) + 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(fetchActivityEventsByTransactionHashesMock).toHaveBeenCalledWith(new Map([[1, ['0xroute']]]), 'all') - expect(response.entries).toEqual([ + expect(allResponse.entries).toEqual([ { chainId: 1, - txHash: '0xroute', - timestamp: 400, - action: 'withdraw', - vaultAddress: UNDERLYING_VAULT, - familyVaultAddress: UNDERLYING_VAULT, - assetSymbol: 'USDC', - assetAmount: '1072609', - assetAmountFormatted: 1.072609, + 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 index 1217ba94a..8e4534fd1 100644 --- a/api/lib/holdings/services/activity.ts +++ b/api/lib/holdings/services/activity.ts @@ -1,22 +1,66 @@ -import type { DepositEvent, TransferEvent, WithdrawEvent } from '../types' +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 } from './graphql' -import { formatAmount, lowerCaseAddress, minBigInt, toVaultKey, ZERO } from './pnlShared' +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' +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' @@ -27,6 +71,9 @@ export interface HoldingsActivityResponse { version: VaultVersion limit: number offset: number + facets?: { + chainIds: number[] + } pageInfo: { hasMore: boolean nextOffset: number | null @@ -51,6 +98,8 @@ type TActivityEvent = blockTimestamp: number logIndex: number transactionHash: string + owner: string + sender: string assets: bigint shares: bigint scopes: TActivityScopes @@ -96,10 +145,21 @@ type TResolvedActivityEvent = { 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 @@ -111,6 +171,42 @@ type TDepositWithdrawalSums = { 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 @@ -149,6 +245,8 @@ function normalizeDepositEvent(event: DepositEvent, scope: 'address' | 'tx'): TA 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) @@ -228,6 +326,44 @@ function buildTransactionHashesByChain(transactionKeys: string[]): 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 @@ -304,6 +440,8 @@ function createResolvedActivityEvent(args: { event: TActivityEvent assetAmount: bigint shareAmount: bigint + transferDirection?: 'in' | 'out' | null + usesRouter?: boolean }): TResolvedActivityEvent { return { chainId: args.event.chainId, @@ -314,11 +452,40 @@ function createResolvedActivityEvent(args: { vaultAddress: args.event.vaultAddress, familyVaultAddress: args.event.familyVaultAddress, action: args.action, + displayType: null, + transferDirection: args.transferDirection ?? null, assets: args.assetAmount, - shares: args.shareAmount + 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( @@ -362,7 +529,7 @@ function classifyTxFamilyEvents(events: TActivityEvent[], userAddress: string): (event) => !event.isStakingVault && event.scopes.address && event.sender === normalizedUserAddress ) - return [ + const classifiedEvents = [ directDeposits.latestEvent && directDeposits.shares > ZERO ? createResolvedActivityEvent({ action: 'deposit', @@ -379,7 +546,8 @@ function classifyTxFamilyEvents(events: TActivityEvent[], userAddress: string): action: 'deposit', event: txDeposits.latestEvent, assetAmount: scaleAmountByMatchedShares(txDeposits.assets, txDeposits.shares, matchedDepositShares), - shareAmount: matchedDepositShares + shareAmount: matchedDepositShares, + usesRouter: true }) : null })() @@ -404,7 +572,8 @@ function classifyTxFamilyEvents(events: TActivityEvent[], userAddress: string): txWithdrawals.shares, matchedWithdrawalShares ), - shareAmount: matchedWithdrawalShares + shareAmount: matchedWithdrawalShares, + usesRouter: true }) : null })() @@ -425,7 +594,8 @@ function classifyTxFamilyEvents(events: TActivityEvent[], userAddress: string): action: 'stake', event: txStakes.latestEvent, assetAmount: matchedStakeAssets, - shareAmount: scaleAmountByMatchedShares(txStakes.shares, txStakes.assets, matchedStakeAssets) + shareAmount: scaleAmountByMatchedShares(txStakes.shares, txStakes.assets, matchedStakeAssets), + usesRouter: true }) : null })() @@ -446,12 +616,15 @@ function classifyTxFamilyEvents(events: TActivityEvent[], userAddress: string): action: 'unstake', event: txUnstakes.latestEvent, assetAmount: matchedUnstakeAssets, - shareAmount: scaleAmountByMatchedShares(txUnstakes.shares, txUnstakes.assets, 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[] { @@ -466,14 +639,164 @@ function classifyActivityEvents(events: TActivityEvent[], userAddress: string): .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 + attempt: number, + maxTimestamp?: number ): Promise { - const recentEvents = await fetchRecentAddressScopedActivityEvents(userAddress, version, limitPerSource) + 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')), @@ -498,21 +821,24 @@ async function loadRecentActivityWindowAttempt( version, targetTransactionCount, Math.min(limitPerSource * 2, 1000), - attempt + 1 + attempt + 1, + maxTimestamp ) } async function loadRecentActivityWindow( userAddress: string, version: VaultVersion, - targetTransactionCount: number + targetTransactionCount: number, + maxTimestamp?: number ): Promise { return loadRecentActivityWindowAttempt( userAddress, version, targetTransactionCount, Math.max(targetTransactionCount * 4, 20), - 0 + 0, + maxTimestamp ) } @@ -524,91 +850,614 @@ function normalizeTransactionActivityEvents(transactionEvents: TransactionActivi ] } -export async function getHoldingsActivity( +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, - version: VaultVersion = 'all', - limit = 10, - offset = 0 -): Promise { - const boundedLimit = Math.max(1, limit) - const boundedOffset = Math.max(0, offset) - const targetTransactionCount = boundedOffset + boundedLimit + 1 - const { candidateEvents, hasPotentialMore } = await loadRecentActivityWindow( - userAddress, - version, - targetTransactionCount + 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 + }) ) - const selectedTransactionKeys = getSelectedTransactionKeys(candidateEvents, targetTransactionCount) - const pageTransactionKeys = selectedTransactionKeys.slice(boundedOffset, boundedOffset + boundedLimit) - const selectedTransactionKeySet = new Set(pageTransactionKeys) +} + +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 = - pageTransactionKeys.length > 0 - ? await fetchActivityEventsByTransactionHashes(buildTransactionHashesByChain(pageTransactionKeys), version) - : { - deposits: [], - withdrawals: [], - transfers: [] - } + transactionKeys.length > 0 + ? await fetchActivityEventsByTransactionHashes(buildTransactionHashesByChain(transactionKeys), version) + : emptyTransactionActivityEvents() const selectedEvents = mergeActivityEvents([ ...selectedAddressEvents, ...normalizeTransactionActivityEvents(transactionEvents) ]) - const classifiedEvents = classifyActivityEvents(selectedEvents, userAddress) - const vaultIdentifiers = classifiedEvents.reduce>( - (identifiers, event) => { + 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( - (identifier) => identifier.chainId === event.chainId && identifier.vaultAddress === event.vaultAddress + (existing) => existing.chainId === identifier.chainId && existing.vaultAddress === identifier.vaultAddress ) if (!alreadyIncluded) { - identifiers.push({ chainId: event.chainId, vaultAddress: event.vaultAddress }) + 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 entries = classifiedEvents.flatMap((event) => { - const eventMetadata = metadata.get(toVaultKey(event.chainId, event.vaultAddress)) ?? null + const visibleEvents = filterVisibleActivityEvents(events, metadata) + const enrichedEvents = await enrichActivityInputAssets(visibleEvents, userAddress, metadata) - if (eventMetadata?.isHidden) { - return [] - } + return { + entries: enrichedEvents.map((event) => toHoldingsActivityEntry(event, metadata)), + metadata + } +} - return [ - { - chainId: event.chainId, - txHash: event.txHash, - timestamp: event.timestamp, - action: event.action, - vaultAddress: event.vaultAddress, - familyVaultAddress: event.familyVaultAddress, - assetSymbol: eventMetadata?.token.symbol ?? null, - assetAmount: event.assets.toString(), - assetAmountFormatted: eventMetadata ? formatAmount(event.assets, eventMetadata.token.decimals) : null, - shareAmount: event.shares.toString(), - shareAmountFormatted: eventMetadata ? formatAmount(event.shares, eventMetadata.decimals) : null, - status: eventMetadata ? 'ok' : 'missing_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, - pageInfo: { - hasMore, - nextOffset: hasMore ? boundedOffset + boundedLimit : null - }, - entries + ...(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 index 8165ca016..06c45701f 100644 --- a/api/lib/holdings/services/aggregator.test.ts +++ b/api/lib/holdings/services/aggregator.test.ts @@ -16,8 +16,10 @@ 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, @@ -52,6 +54,7 @@ vi.mock('./kong', () => ({ vi.mock('./defillama', () => ({ fetchHistoricalPrices: fetchHistoricalPricesMock, fetchHistoricalPricesForTokenTimestamps: fetchHistoricalPricesMock, + getHistoricalPriceFetchFailedBatches: getHistoricalPriceFetchFailedBatchesMock, getChainPrefix: getChainPrefixMock, getPriceAtTimestamp: getPriceAtTimestampMock })) @@ -61,6 +64,7 @@ describe('getHistoricalHoldings', () => { toSettledDayTimestampMock.mockImplementation((timestamp: number) => timestamp + 1) checkCacheStalenessMock.mockResolvedValue(false) clearUserCacheMock.mockResolvedValue(0) + getHistoricalPriceFetchFailedBatchesMock.mockReturnValue(0) }) afterEach(() => { @@ -143,10 +147,16 @@ describe('getHistoricalHoldings', () => { const { getHistoricalHoldings } = await import('./aggregator') const response = await getHistoricalHoldings(userAddress, 'v2', 'parallel', 'all') - expect(fetchUserEventsMock).toHaveBeenCalledWith(userAddress, 'all', 86501, 'parallel', 'all') - expect(getCachedTotalsWithTimestampMock).toHaveBeenCalledWith(userAddress, 'v2', 'date-100', 'date-100') + expect(fetchUserEventsMock).toHaveBeenCalledWith( + userAddress, + 'all', + 101 + CURRENT_DAY_LOOKAHEAD_SECONDS, + 'parallel', + 'all' + ) + expect(getCachedTotalsWithTimestampMock).toHaveBeenCalledWith(userAddress, 'v2:v2', 'date-100', 'date-100') expect(fetchMultipleVaultsPPSMock).toHaveBeenCalledWith([vaults[0]]) - expect(saveCachedTotalsMock).toHaveBeenCalledWith(userAddress, 'v2', [{ date: 'date-100', usdValue: 2 }]) + expect(saveCachedTotalsMock).toHaveBeenCalledWith(userAddress, 'v2:v2', [{ date: 'date-100', usdValue: 2 }]) expect(response.hasActivity).toBe(true) expect(response.dataPoints).toEqual([{ date: 'date-100', timestamp: 101, totalUsdValue: 2 }]) }) @@ -169,7 +179,82 @@ describe('getHistoricalHoldings', () => { const { getHistoricalHoldings } = await import('./aggregator') await getHistoricalHoldings(userAddress, 'all') - expect(fetchUserEventsMock).toHaveBeenCalledWith(userAddress, 'all', 86501, 'seq', 'paged') + 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 () => { @@ -217,7 +302,13 @@ describe('getHistoricalHoldings', () => { const { getHistoricalHoldings } = await import('./aggregator') const response = await getHistoricalHoldings(userAddress, 'all') - expect(fetchUserEventsMock).toHaveBeenCalledWith(userAddress, 'all', 86601, 'seq', 'paged') + expect(fetchUserEventsMock).toHaveBeenCalledWith( + userAddress, + 'all', + 201 + CURRENT_DAY_LOOKAHEAD_SECONDS, + 'seq', + 'paged' + ) expect(fetchMultipleVaultsMetadataMock).toHaveBeenCalled() expect(checkCacheStalenessMock).toHaveBeenCalledWith( [{ address: vaultAddress, chainId: 1 }], @@ -302,11 +393,12 @@ describe('getHistoricalHoldings', () => { const { getHistoricalHoldings } = await import('./aggregator') const response = await getHistoricalHoldings(userAddress, 'all') - expect(clearUserCacheMock).toHaveBeenCalledWith(userAddress, 'all') + expect(clearUserCacheMock).toHaveBeenCalledWith(userAddress, 'all:v2') expect(fetchMultipleVaultsPPSMock).toHaveBeenCalled() - expect(fetchHistoricalPricesMock).toHaveBeenCalledWith([ - { chainId: 1, address: tokenAddress, timestamps: [101, 201] } - ]) + 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([ @@ -359,7 +451,7 @@ describe('getHistoricalHoldings', () => { expect(response.dataPoints).toEqual([{ date: 'date-100', timestamp: 101, totalUsdValue: 0 }]) }) - it('expands all timeframe from the first wallet event to the latest settled day', async () => { + it('expands all timeframe from the supported history start to the latest settled day', async () => { const userAddress = '0x93a62da5a14c80f265dabc077fcee437b1a0efde' const vaultAddress = '0x00000000000000000000000000000000000000b2' const tokenAddress = '0x0000000000000000000000000000000000000bb2' @@ -418,9 +510,9 @@ describe('getHistoricalHoldings', () => { const { getHistoricalHoldingsChart } = await import('./aggregator') const response = await getHistoricalHoldingsChart(userAddress, 'all', 'parallel', 'all', 'usd', 'all') - expect(generateDailyTimestampsFromRangeMock).toHaveBeenCalledWith(50, 200) - expect(getCachedTotalsWithTimestampMock).not.toHaveBeenCalled() - expect(saveCachedTotalsMock).toHaveBeenCalledWith(userAddress, 'all', [ + expect(generateDailyTimestampsFromRangeMock).toHaveBeenCalledWith(1_704_067_200, 200) + expect(getCachedTotalsWithTimestampMock).toHaveBeenCalledWith(userAddress, 'all:v2', 'date-50', 'date-200') + expect(saveCachedTotalsMock).toHaveBeenCalledWith(userAddress, 'all:v2', [ { date: 'date-50', usdValue: 1 }, { date: 'date-100', usdValue: 1 }, { date: 'date-200', usdValue: 1 } @@ -434,11 +526,14 @@ describe('getHistoricalHoldings', () => { ]) }) - it('marks history as active even when only unsettled same-day events exist', async () => { + it('does not cache recalculated totals after partial price fetch failures', async () => { const userAddress = '0x93a62da5a14c80f265dabc077fcee437b1a0efde' - const sameDayTimeline = [{ blockTimestamp: 250, blockNumber: 2 }] + const vaultAddress = '0x00000000000000000000000000000000000000b2' + const tokenAddress = '0x0000000000000000000000000000000000000bb2' + const timeline = [{ blockTimestamp: 100, blockNumber: 1 }] + const vaults = [{ chainId: 1, vaultAddress }] - generateDailyTimestampsMock.mockReturnValue([100, 200]) + generateDailyTimestampsMock.mockReturnValue([100]) timestampToDateStringMock.mockImplementation((timestamp: number) => `date-${timestamp}`) getCachedTotalsWithTimestampMock.mockResolvedValue({ totals: [], oldestUpdatedAt: null }) fetchUserEventsMock.mockResolvedValue({ @@ -447,14 +542,90 @@ describe('getHistoricalHoldings', () => { transfersIn: [], transfersOut: [] }) - buildPositionTimelineMock.mockReturnValue(sameDayTimeline) + 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', 86601, 'seq', 'paged') + 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 }, @@ -500,7 +671,7 @@ describe('getHistoricalHoldings', () => { ]) ) fetchMultipleVaultsPPSMock.mockResolvedValue(new Map([[`1:${vaultAddress}`, new Map([[201, 1.5]])]])) - fetchHistoricalPricesMock.mockResolvedValue(new Map([[`ethereum:${tokenAddress}`, new Map([[201, 2]])]])) + fetchHistoricalPricesMock.mockResolvedValue(new Map([[`ethereum:${tokenAddress}`, new Map([[200, 2]])]])) getChainPrefixMock.mockReturnValue('ethereum') getPPSMock.mockReturnValue(1.5) getPriceAtTimestampMock.mockReturnValue(2) @@ -510,7 +681,9 @@ describe('getHistoricalHoldings', () => { 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] }]) + expect(fetchHistoricalPricesMock).toHaveBeenCalledWith([{ chainId: 1, address: tokenAddress, timestamps: [201] }], { + resolution: 'utc_day' + }) expect(getShareBalanceAtTimestampMock).toHaveBeenCalledWith(timeline, vaultAddress, 1, 201) expect(response).toEqual({ address: userAddress, @@ -586,7 +759,7 @@ describe('getHistoricalHoldings', () => { ]) ) fetchMultipleVaultsPPSMock.mockResolvedValue(new Map([[`1:${vaultAddress}`, new Map([[101, 3]])]])) - fetchHistoricalPricesMock.mockResolvedValue(new Map([[`ethereum:${tokenAddress}`, new Map([[101, 4]])]])) + fetchHistoricalPricesMock.mockResolvedValue(new Map([[`ethereum:${tokenAddress}`, new Map([[100, 4]])]])) getChainPrefixMock.mockReturnValue('ethereum') getPPSMock.mockReturnValue(3) getPriceAtTimestampMock.mockReturnValue(4) @@ -596,7 +769,9 @@ describe('getHistoricalHoldings', () => { 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] }]) + 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) diff --git a/api/lib/holdings/services/aggregator.ts b/api/lib/holdings/services/aggregator.ts index 1431b2027..4b0cb63b3 100644 --- a/api/lib/holdings/services/aggregator.ts +++ b/api/lib/holdings/services/aggregator.ts @@ -1,12 +1,13 @@ -import { config } from '../config' +import { holdingsConfig } from '../config' import type { VaultMetadata } from '../types' import type { CachedTotal } from './cache' import { checkCacheStaleness, clearUserCache, getCachedTotalsWithTimestamp, saveCachedTotals } from './cache' -import { debugLog } from './debug' +import { debugLog, reportHoldingsProgress } from './debug' import { fetchHistoricalPrices, fetchHistoricalPricesForTokenTimestamps, getChainPrefix, + getHistoricalPriceFetchFailedBatches, getPriceAtTimestamp } from './defillama' import { @@ -28,11 +29,12 @@ import { fetchMultipleVaultsPPS, getPPS } from './kong' import { deriveNestedVaultAssetPriceData, expandNestedVaultAssetPriceRequests, - getAssetVaultMetadataLookupIdentifiers, getNestedVaultPpsIdentifiersFromPriceRequests, - mergeVaultIdentifiers + mergeVaultIdentifiers, + resolveNestedVaultAssetMetadata } from './nestedVaultPrices' import { toVaultKey } from './pnlShared' +import { getSettledAddressScopedContext, getSettledVersionedPpsContext } from './settledHoldingsContext' import { fetchMultipleVaultsMetadata } from './vaults' export interface HoldingsHistoryResponse { @@ -45,6 +47,7 @@ export interface HoldingsHistoryResponse { export type HoldingsHistoryDenomination = 'usd' | 'eth' export type HoldingsHistoryTimeframe = '1y' | 'all' +export type HoldingsVaultFilter = { chainId: number; vaultAddress: string } export interface HoldingsHistoryChartResponse { address: string @@ -56,19 +59,7 @@ export interface HoldingsHistoryChartResponse { } const ETHEREUM_WETH_ADDRESS = '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2' - -async function resolveNestedVaultAssetMetadata( - vaultMetadata: Map -): Promise> { - const assetVaultIdentifiers = getAssetVaultMetadataLookupIdentifiers(vaultMetadata) - - if (assetVaultIdentifiers.length === 0) { - return vaultMetadata - } - - const assetVaultMetadata = await fetchMultipleVaultsMetadata(assetVaultIdentifiers, { skipSnapshotFallback: true }) - return new Map([...vaultMetadata, ...assetVaultMetadata]) -} +const HOLDINGS_TOTALS_CACHE_VERSION = 'v2' export interface HoldingsBreakdownVaultResponse { chainId: number @@ -108,6 +99,10 @@ export interface HoldingsBreakdownResponse { message?: string } +export function getHoldingsTotalsCacheVersion(version: VaultVersion): string { + return `${version}:${HOLDINGS_TOTALS_CACHE_VERSION}` +} + function filterVaultsByAuthoritativeVersion< TVault extends { chainId: number @@ -129,6 +124,20 @@ function filterVaultsByAuthoritativeVersion< }) } +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, @@ -163,13 +172,23 @@ export async function getHistoricalHoldings( version: VaultVersion = 'all', fetchType: HoldingsEventFetchType = 'seq', paginationMode: HoldingsEventPaginationMode = 'paged', - timeframe: HoldingsHistoryTimeframe = '1y' + timeframe: HoldingsHistoryTimeframe = '1y', + requestedVaults?: HoldingsVaultFilter[] ): Promise { - const defaultDays = config.historyDays + const defaultDays = holdingsConfig.historyDays + const baseContext = await getSettledAddressScopedContext({ + userAddress, + fetchType, + paginationMode + }) + reportHoldingsProgress(18, 'Loaded wallet events', null) const dayTimestamps = generateDailyTimestamps(defaultDays, 1) - const latestSettledDayTimestamp = dayTimestamps[dayTimestamps.length - 1] - let timestamps = timeframe === 'all' ? [] : dayTimestamps - let periodDays = defaultDays + 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, @@ -183,10 +202,13 @@ export async function getHistoricalHoldings( // Fetch cached totals with timestamp info for staleness check let cachedTotals: CachedTotal[] = [] let oldestUpdatedAt: Date | null = null - if (timeframe !== 'all') { + 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, version, startDate, endDate) + const cachedResult = await getCachedTotalsWithTimestamp(userAddress, cacheVersion, startDate, endDate) cachedTotals = cachedResult.totals oldestUpdatedAt = cachedResult.oldestUpdatedAt } @@ -196,49 +218,42 @@ export async function getHistoricalHoldings( 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])) - // Always fetch the full event set, then filter vaults by authoritative Kong metadata version. - const maxTimestamp = toSettledDayTimestamp(latestSettledDayTimestamp + 86400) - const events = await fetchUserEvents(userAddress, 'all', maxTimestamp, fetchType, paginationMode) - const timeline = buildPositionTimeline(events.deposits, events.withdrawals, events.transfersIn, events.transfersOut) - const hasActivity = timeline.length > 0 + const timeline = baseContext.timeline + const hasActivity = baseContext.hasActivity debugLog('history', 'built position timeline', { fetchType, paginationMode, - deposits: events.deposits.length, - withdrawals: events.withdrawals.length, - transfersIn: events.transfersIn.length, - transfersOut: events.transfersOut.length, + 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`) - if (timeframe === 'all' && timeline.length > 0) { - const firstEventTimestamp = timeline[0]?.blockTimestamp ?? timestamps[0] - const allTimestamps = generateDailyTimestampsFromRange(firstEventTimestamp, latestSettledDayTimestamp) - - if (allTimestamps.length > 0) { - timestamps = allTimestamps - periodDays = allTimestamps.length - } - } - - const rawVaults = timeline.length > 0 ? getUniqueVaults(timeline) : [] - const baseVaultMetadata = rawVaults.length > 0 ? await fetchMultipleVaultsMetadata(rawVaults) : new Map() - const vaultMetadata = await resolveNestedVaultAssetMetadata(baseVaultMetadata) - const vaults = filterVaultsByAuthoritativeVersion(rawVaults, vaultMetadata, version) + 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: rawVaults.length, + 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 (cachedTotals.length > 0 && vaults.length > 0) { + 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', { @@ -251,7 +266,7 @@ export async function getHistoricalHoldings( if (isStale) { console.log(`[Aggregator] Cache stale for ${userAddress}, clearing and recalculating`) - await clearUserCache(userAddress, version) + await clearUserCache(userAddress, cacheVersion) cachedTotals = [] oldestUpdatedAt = null cachedByDate = new Map() @@ -272,6 +287,7 @@ export async function getHistoricalHoldings( dataPoints: dataPoints.length, oldestUpdatedAt: oldestUpdatedAt?.toISOString() ?? null }) + reportHoldingsProgress(94, 'Loaded cached historical chart data', `${dataPoints.length} chart points`) return { address: userAddress, @@ -289,8 +305,10 @@ export async function getHistoricalHoldings( 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 @@ -327,24 +345,36 @@ export async function getHistoricalHoldings( })) } } else { - const seenTokens = new Set() - const underlyingTokens: Array<{ chainId: number; address: string }> = [] - for (const vault of vaults) { - 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 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, @@ -355,15 +385,14 @@ export async function getHistoricalHoldings( ...vaults, ...getNestedVaultPpsIdentifiersFromPriceRequests(basePriceRequests, vaultMetadata) ]) - const [ppsData, fetchedPriceData] = await Promise.all([ - fetchMultipleVaultsPPS(ppsIdentifiers), - fetchHistoricalPricesForTokenTimestamps(priceRequests) - ]) + 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 + ppsData: ppsContext.ppsData }) debugLog('history', 'resolved metadata and PPS for history', { version, @@ -371,8 +400,8 @@ export async function getHistoricalHoldings( paginationMode, vaults: ppsIdentifiers.length, metadataResolved: vaultMetadata.size, - ppsResolved: ppsData.size, - emptyPpsTimelines: Array.from(ppsData.values()).filter((timeline) => timeline.size === 0).length + ppsResolved: ppsContext.ppsData.size, + emptyPpsTimelines: Array.from(ppsContext.ppsData.values()).filter((timeline) => timeline.size === 0).length }) debugLog('history', 'resolved historical token prices', { version, @@ -380,7 +409,8 @@ export async function getHistoricalHoldings( paginationMode, tokens: priceRequests.length, priceKeys: priceData.size, - missingTimestamps: missingTimestamps.length + missingTimestamps: missingTimestamps.length, + failedPriceBatches }) for (const timestamp of missingTimestamps) { @@ -397,7 +427,7 @@ export async function getHistoricalHoldings( if (shares === BigInt(0)) continue - const ppsMap = ppsData.get(vaultKey) + const ppsMap = ppsContext.ppsData.get(vaultKey) const pps = ppsMap ? getPPS(ppsMap, valuationTimestamp) : null if (pps === null) continue @@ -422,16 +452,35 @@ export async function getHistoricalHoldings( newTotals: newTotals.length, nonZeroTotals: newTotals.filter((total) => total.usdValue > 0).length }) + reportHoldingsProgress(88, 'Calculated uncached chart history', `${newTotals.length} daily totals`) } - if (newTotals.length > 0) { - await saveCachedTotals(userAddress, version, newTotals) - debugLog('history', 'saved recalculated totals to cache', { + 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 + newTotals: newTotals.length, + failedPriceBatches }) + reportHoldingsProgress(92, 'Skipped historical chart cache save', `${failedPriceBatches} price batches failed`) } } @@ -452,6 +501,7 @@ export async function getHistoricalHoldings( dataPoints: dataPoints.length, nonZeroPoints: dataPoints.filter((point) => point.totalUsdValue > 0).length }) + reportHoldingsProgress(96, 'Prepared historical chart data', `${dataPoints.length} chart points`) return { address: userAddress, @@ -468,9 +518,17 @@ export async function getHistoricalHoldingsChart( fetchType: HoldingsEventFetchType = 'seq', paginationMode: HoldingsEventPaginationMode = 'paged', denomination: HoldingsHistoryDenomination = 'usd', - timeframe: HoldingsHistoryTimeframe = '1y' + timeframe: HoldingsHistoryTimeframe = '1y', + requestedVaults?: HoldingsVaultFilter[] ): Promise { - const holdings = await getHistoricalHoldings(userAddress, version, fetchType, paginationMode, timeframe) + const holdings = await getHistoricalHoldings( + userAddress, + version, + fetchType, + paginationMode, + timeframe, + requestedVaults + ) if (denomination === 'usd') { return { @@ -515,16 +573,18 @@ export async function getHoldingsBreakdown( paginationMode: HoldingsEventPaginationMode = 'paged', targetTimestamp?: number ): Promise { - const timestamps = generateDailyTimestamps(config.historyDays, 1) + 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 + date: breakdownDate, + priceTimestamp: breakdownPriceTimestamp }) const maxTimestamp = breakdownDayTimestamp + 86400 @@ -572,9 +632,34 @@ export async function getHoldingsBreakdown( 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 vaults) { + for (const vault of activeVaults) { const metadata = vaultMetadata.get(toVaultKey(vault.chainId, vault.vaultAddress)) if (!metadata) { continue @@ -592,16 +677,18 @@ export async function getHoldingsBreakdown( const basePriceRequests = underlyingTokens.map((token) => ({ ...token, - timestamps: [breakdownTimestamp] + timestamps: [breakdownPriceTimestamp] })) const priceRequests = expandNestedVaultAssetPriceRequests(basePriceRequests, vaultMetadata) const ppsIdentifiers = mergeVaultIdentifiers([ - ...vaults, + ...activeVaults, ...getNestedVaultPpsIdentifiersFromPriceRequests(basePriceRequests, vaultMetadata) ]) const [ppsData, fetchedPriceData] = await Promise.all([ - fetchMultipleVaultsPPS(ppsIdentifiers), - fetchHistoricalPricesForTokenTimestamps(priceRequests) + 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, @@ -618,19 +705,18 @@ export async function getHoldingsBreakdown( ppsResolved: ppsData.size, tokens: priceRequests.length, priceKeys: priceData.size, - timestamp: breakdownTimestamp + timestamp: breakdownTimestamp, + priceTimestamp: breakdownPriceTimestamp, + activeVaults: activeVaults.length }) const results: HoldingsBreakdownVaultResponse[] = [] - for (const vault of vaults) { + for (const vault of activeVaults) { const vaultKey = toVaultKey(vault.chainId, vault.vaultAddress) const metadata = vaultMetadata.get(vaultKey) - const shares = getShareBalanceAtTimestamp(timeline, vault.vaultAddress, vault.chainId, breakdownTimestamp) const ppsMap = ppsData.get(vaultKey) const pps = ppsMap ? getPPS(ppsMap, breakdownTimestamp) : null - const decimals = metadata?.decimals ?? 18 - const sharesFormatted = Number(shares) / 10 ** decimals let tokenPrice: number | null = null let usdValue: number | null = null @@ -638,8 +724,8 @@ export async function getHoldingsBreakdown( if (metadata) { const priceKey = `${getChainPrefix(metadata.chainId)}:${metadata.token.address.toLowerCase()}` const tokenPriceMap = priceData.get(priceKey) - tokenPrice = tokenPriceMap ? getPriceAtTimestamp(tokenPriceMap, breakdownTimestamp) : 0 - usdValue = pps ? sharesFormatted * pps * tokenPrice : 0 + tokenPrice = tokenPriceMap ? getPriceAtTimestamp(tokenPriceMap, breakdownPriceTimestamp) : 0 + usdValue = pps ? vault.sharesFormatted * pps * tokenPrice : 0 } let status: HoldingsBreakdownVaultResponse['status'] = 'ok' @@ -654,8 +740,8 @@ export async function getHoldingsBreakdown( results.push({ chainId: vault.chainId, vaultAddress: vault.vaultAddress, - shares: shares.toString(), - sharesFormatted, + shares: vault.shares.toString(), + sharesFormatted: vault.sharesFormatted, pricePerShare: pps, tokenPrice, usdValue, diff --git a/api/lib/holdings/services/cache.test.ts b/api/lib/holdings/services/cache.test.ts index c19b61d54..562eff05b 100644 --- a/api/lib/holdings/services/cache.test.ts +++ b/api/lib/holdings/services/cache.test.ts @@ -8,30 +8,32 @@ vi.mock('../db/connection', () => ({ isDatabaseEnabled: isDatabaseEnabledMock })) -describe('deleteStaleCache', () => { +describe('cache writes', () => { beforeEach(() => { vi.clearAllMocks() }) - it('prunes stale rate limit rows during cache cleanup', async () => { - const queryMock = vi - .fn() - .mockResolvedValueOnce({ rowCount: 2 }) - .mockResolvedValueOnce({ rowCount: 3 }) - .mockResolvedValueOnce({ rowCount: 4 }) - + it('saves historical totals in smaller batches', async () => { + const queryMock = vi.fn().mockResolvedValue({ rowCount: 250 }) isDatabaseEnabledMock.mockReturnValue(true) getPoolMock.mockResolvedValue({ query: queryMock, end: vi.fn() }) - const { deleteStaleCache } = await import('./cache') - const deletedCount = await deleteStaleCache() + const { saveCachedTotals } = await import('./cache') + const totals = Array.from({ length: 497 }, (_value, index) => ({ + date: `2025-01-${String((index % 28) + 1).padStart(2, '0')}`, + usdValue: index + })) + + const saved = await saveCachedTotals('0x0000000000000000000000000000000000000001', 'all', totals) - expect(queryMock).toHaveBeenNthCalledWith(1, expect.stringContaining('DELETE FROM holdings_totals')) - expect(queryMock).toHaveBeenNthCalledWith(2, expect.stringContaining('DELETE FROM token_price_misses')) - expect(queryMock).toHaveBeenNthCalledWith(3, expect.stringContaining('DELETE FROM rate_limits')) - expect(deletedCount).toBe(9) + expect(saved).toBe(true) + expect(queryMock).toHaveBeenCalledTimes(2) + expect(queryMock.mock.calls[0]?.[1]).toHaveLength(1_000) + expect(queryMock.mock.calls[1]?.[1]).toHaveLength(988) + expect(queryMock.mock.calls[0]?.[2]).toEqual({ disableOnFailure: false }) + expect(queryMock.mock.calls[1]?.[2]).toEqual({ disableOnFailure: false }) }) }) diff --git a/api/lib/holdings/services/cache.ts b/api/lib/holdings/services/cache.ts index 7d0388697..969fc76d7 100644 --- a/api/lib/holdings/services/cache.ts +++ b/api/lib/holdings/services/cache.ts @@ -1,31 +1,12 @@ import { createHash } from 'node:crypto' import { getPool, isDatabaseEnabled } from '../db/connection' import { debugError, debugLog } from './debug' -import type { VaultVersion } from './graphql' export interface CachedTotal { date: string usdValue: number } -export interface CachedPrice { - tokenKey: string - timestamp: number - price: number -} - -export interface CachedPriceMiss { - tokenKey: string - timestamp: number -} - -export interface CachedPriceLookup { - tokenKey: string - timestamps: number[] -} - -const PRICE_MISS_TTL_MS = 7 * 24 * 60 * 60 * 1_000 - function normalizeUserAddress(userAddress: string): string { return userAddress.toLowerCase() } @@ -34,40 +15,9 @@ function getUserAddressCacheKey(userAddress: string): string { return createHash('sha256').update(normalizeUserAddress(userAddress)).digest('hex') } -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 normalizeCachedPriceLookups(lookups: CachedPriceLookup[]): CachedPriceLookup[] { - const timestampsByToken = lookups.reduce>>((result, lookup) => { - if (lookup.timestamps.length === 0) { - return result - } - - const tokenKey = lookup.tokenKey.toLowerCase() - const timestampSet = result.get(tokenKey) ?? new Set() - lookup.timestamps.forEach((timestamp) => { - timestampSet.add(timestamp) - }) - result.set(tokenKey, timestampSet) - return result - }, new Map()) - - return Array.from(timestampsByToken.entries()).map(([tokenKey, timestampSet]) => ({ - tokenKey, - timestamps: Array.from(timestampSet).sort((a, b) => a - b) - })) -} - -function countCachedPriceLookupPoints(lookups: CachedPriceLookup[]): number { - return lookups.reduce((total, lookup) => total + lookup.timestamps.length, 0) -} - export async function getCachedTotals( userAddress: string, - version: VaultVersion, + version: string, startDate: string, endDate: string ): Promise { @@ -106,22 +56,18 @@ export async function getCachedTotals( } } -export async function saveCachedTotals( - userAddress: string, - version: VaultVersion, - totals: CachedTotal[] -): Promise { +export async function saveCachedTotals(userAddress: string, version: string, totals: CachedTotal[]): Promise { if (!isDatabaseEnabled() || totals.length === 0) { if (totals.length > 0) { debugLog('cache', 'skipping cached totals save because database is disabled', { rows: totals.length }) } - return + return false } const pool = await getPool() if (!pool) { debugLog('cache', 'skipping cached totals save because database pool is unavailable', { rows: totals.length }) - return + return false } try { @@ -131,322 +77,44 @@ export async function saveCachedTotals( version, rows: totals.length }) - const values: unknown[] = [] - const placeholders: string[] = [] - let paramIndex = 1 - - for (const total of totals) { - placeholders.push(`($${paramIndex}, $${paramIndex + 1}, $${paramIndex + 2}, $${paramIndex + 3})`) - values.push(userAddressHash, version, total.date, total.usdValue) - paramIndex += 4 - } - - const query = ` - INSERT INTO holdings_totals (user_address_hash, version, date, usd_value) - VALUES ${placeholders.join(', ')} - ON CONFLICT (user_address_hash, version, date) - DO UPDATE SET usd_value = EXCLUDED.usd_value, updated_at = NOW() - ` - - await pool.query(query, values) - debugLog('cache', 'saved cached totals', { rows: totals.length }) - } catch (error) { - console.error('[Cache] Failed to save totals:', error) - debugError('cache', 'cached totals save failed', error, { rows: totals.length }) - } -} - -export async function getCachedPrices( - tokenKeys: string[], - timestamps: number[] -): Promise>> { - return getCachedPricesForTokenTimestamps(tokenKeys.map((tokenKey) => ({ tokenKey, timestamps }))) -} - -export async function getCachedPricesForTokenTimestamps( - lookups: CachedPriceLookup[] -): Promise>> { - const result = new Map>() - const normalizedLookups = normalizeCachedPriceLookups(lookups) - const pricePoints = countCachedPriceLookupPoints(normalizedLookups) - - if (!isDatabaseEnabled() || normalizedLookups.length === 0 || pricePoints === 0) { - if (normalizedLookups.length > 0 && pricePoints > 0) { - debugLog('cache', 'skipping cached prices lookup because database is disabled', { - tokenKeys: normalizedLookups.length, - pricePoints - }) - } - return result - } - - const pool = await getPool() - if (!pool) { - debugLog('cache', 'skipping cached prices lookup because database pool is unavailable', { - tokenKeys: normalizedLookups.length, - pricePoints - }) - return result - } - - try { - debugLog('cache', 'loading cached prices', { tokenKeys: normalizedLookups.length, pricePoints }) - const lookupPairs = normalizedLookups.flatMap((lookup) => - lookup.timestamps.map((timestamp) => ({ tokenKey: lookup.tokenKey, timestamp })) - ) - - await chunkItems(lookupPairs, 5_000).reduce>(async (previousPromise, batch) => { - await previousPromise - - const values = batch.flatMap((pair) => [pair.tokenKey, pair.timestamp]) - const placeholders = batch.map((_, index) => `($${index * 2 + 1}::text, $${index * 2 + 2}::integer)`).join(', ') - - const query = ` - WITH requested(token_key, price_timestamp) AS ( - VALUES ${placeholders} - ) - SELECT token_prices.token_key, token_prices.timestamp, token_prices.price - FROM token_prices - INNER JOIN requested - ON requested.token_key = token_prices.token_key - AND requested.price_timestamp = token_prices.timestamp - ` - - const queryResult = await pool.query<{ token_key: string; timestamp: number; price: string }>(query, values) - - queryResult.rows.forEach((row) => { - const tokenPrices = result.get(row.token_key) ?? new Map() - tokenPrices.set(row.timestamp, parseFloat(row.price)) - result.set(row.token_key, tokenPrices) - }) - }, Promise.resolve()) - - const cachedPoints = Array.from(result.values()).reduce((total, priceMap) => total + priceMap.size, 0) - debugLog('cache', 'loaded cached prices', { - tokenKeys: result.size, - pricePoints: cachedPoints - }) - } catch (error) { - console.error('[Cache] Failed to get cached prices:', error) - debugError('cache', 'cached prices lookup failed', error, { - tokenKeys: normalizedLookups.length, - pricePoints - }) - } - - return result -} - -export async function getCachedPriceMisses( - tokenKeys: string[], - timestamps: number[] -): Promise>> { - return getCachedPriceMissesForTokenTimestamps(tokenKeys.map((tokenKey) => ({ tokenKey, timestamps }))) -} - -export async function getCachedPriceMissesForTokenTimestamps( - lookups: CachedPriceLookup[] -): Promise>> { - const result = new Map>() - const normalizedLookups = normalizeCachedPriceLookups(lookups) - const pricePoints = countCachedPriceLookupPoints(normalizedLookups) - - if (!isDatabaseEnabled() || normalizedLookups.length === 0 || pricePoints === 0) { - if (normalizedLookups.length > 0 && pricePoints > 0) { - debugLog('cache', 'skipping cached price misses lookup because database is disabled', { - tokenKeys: normalizedLookups.length, - pricePoints - }) - } - return result - } - - const pool = await getPool() - if (!pool) { - debugLog('cache', 'skipping cached price misses lookup because database pool is unavailable', { - tokenKeys: normalizedLookups.length, - pricePoints - }) - return result - } - - try { - debugLog('cache', 'loading cached price misses', { tokenKeys: normalizedLookups.length, pricePoints }) - const lookupPairs = normalizedLookups.flatMap((lookup) => - lookup.timestamps.map((timestamp) => ({ tokenKey: lookup.tokenKey, timestamp })) - ) + const BATCH_SIZE = 250 - await chunkItems(lookupPairs, 5_000).reduce>(async (previousPromise, batch) => { - await previousPromise - - const values = batch.flatMap((pair) => [pair.tokenKey, pair.timestamp]) - const placeholders = batch.map((_, index) => `($${index * 2 + 1}::text, $${index * 2 + 2}::integer)`).join(', ') - - const query = ` - WITH requested(token_key, price_timestamp) AS ( - VALUES ${placeholders} - ) - SELECT token_price_misses.token_key, token_price_misses.timestamp - FROM token_price_misses - INNER JOIN requested - ON requested.token_key = token_price_misses.token_key - AND requested.price_timestamp = token_price_misses.timestamp - WHERE token_price_misses.expires_at > NOW() - ` - - const queryResult = await pool.query<{ token_key: string; timestamp: number }>(query, values) - - queryResult.rows.forEach((row) => { - const tokenMisses = result.get(row.token_key) ?? new Set() - tokenMisses.add(row.timestamp) - result.set(row.token_key, tokenMisses) - }) - }, Promise.resolve()) - - const cachedMissPoints = Array.from(result.values()).reduce((total, timestampSet) => total + timestampSet.size, 0) - debugLog('cache', 'loaded cached price misses', { - tokenKeys: result.size, - missPoints: cachedMissPoints - }) - } catch (error) { - console.error('[Cache] Failed to get cached price misses:', error) - debugError('cache', 'cached price misses lookup failed', error, { - tokenKeys: normalizedLookups.length, - pricePoints - }) - } - - return result -} - -export async function saveCachedPrices(prices: CachedPrice[]): Promise { - if (!isDatabaseEnabled() || prices.length === 0) { - if (prices.length > 0) { - debugLog('cache', 'skipping cached prices save because database is disabled', { rows: prices.length }) - } - return - } - - const pool = await getPool() - if (!pool) { - debugLog('cache', 'skipping cached prices save because database pool is unavailable', { rows: prices.length }) - return - } - - try { - debugLog('cache', 'saving cached prices', { rows: prices.length }) - // Batch insert in chunks of 1000 - const BATCH_SIZE = 1000 - for (let i = 0; i < prices.length; i += BATCH_SIZE) { - const batch = prices.slice(i, i + BATCH_SIZE) - const values: unknown[] = [] - const placeholders: string[] = [] - let paramIndex = 1 - - for (const price of batch) { - placeholders.push(`($${paramIndex}, $${paramIndex + 1}, $${paramIndex + 2})`) - values.push(price.tokenKey, price.timestamp, price.price) - paramIndex += 3 - } - - const query = ` - INSERT INTO token_prices (token_key, timestamp, price) - VALUES ${placeholders.join(', ')} - ON CONFLICT (token_key, timestamp) DO NOTHING - ` - - await pool.query(query, values) - } - debugLog('cache', 'saved cached prices', { rows: prices.length }) - } catch (error) { - console.error('[Cache] Failed to save prices:', error) - debugError('cache', 'cached prices save failed', error, { rows: prices.length }) - } -} - -export async function saveCachedPriceMisses(priceMisses: CachedPriceMiss[]): Promise { - if (!isDatabaseEnabled() || priceMisses.length === 0) { - if (priceMisses.length > 0) { - debugLog('cache', 'skipping cached price misses save because database is disabled', { rows: priceMisses.length }) - } - return - } - - const pool = await getPool() - if (!pool) { - debugLog('cache', 'skipping cached price misses save because database pool is unavailable', { - rows: priceMisses.length - }) - return - } - - try { - debugLog('cache', 'saving cached price misses', { rows: priceMisses.length }) - const expiresAt = new Date(Date.now() + PRICE_MISS_TTL_MS) - const BATCH_SIZE = 1_000 - - for (let i = 0; i < priceMisses.length; i += BATCH_SIZE) { - const batch = priceMisses.slice(i, i + BATCH_SIZE) + for (let i = 0; i < totals.length; i += BATCH_SIZE) { + const batch = totals.slice(i, i + BATCH_SIZE) const values: unknown[] = [] const placeholders: string[] = [] let paramIndex = 1 - for (const priceMiss of batch) { - placeholders.push(`($${paramIndex}, $${paramIndex + 1}, $${paramIndex + 2})`) - values.push(priceMiss.tokenKey, priceMiss.timestamp, expiresAt) - paramIndex += 3 + for (const total of batch) { + placeholders.push(`($${paramIndex}, $${paramIndex + 1}, $${paramIndex + 2}, $${paramIndex + 3})`) + values.push(userAddressHash, version, total.date, total.usdValue) + paramIndex += 4 } const query = ` - INSERT INTO token_price_misses (token_key, timestamp, expires_at) + INSERT INTO holdings_totals (user_address_hash, version, date, usd_value) VALUES ${placeholders.join(', ')} - ON CONFLICT (token_key, timestamp) - DO UPDATE SET expires_at = GREATEST(token_price_misses.expires_at, EXCLUDED.expires_at) + ON CONFLICT (user_address_hash, version, date) + DO UPDATE SET usd_value = EXCLUDED.usd_value, updated_at = NOW() ` - await pool.query(query, values) + await pool.query(query, values, { disableOnFailure: false }) } - - debugLog('cache', 'saved cached price misses', { rows: priceMisses.length }) + debugLog('cache', 'saved cached totals', { rows: totals.length }) + return true } catch (error) { - console.error('[Cache] Failed to save price misses:', error) - debugError('cache', 'cached price misses save failed', error, { rows: priceMisses.length }) + console.error('[Cache] Failed to save totals:', error) + debugError('cache', 'cached totals save failed', error, { rows: totals.length }) + return false } } -export async function deleteStaleCache(): Promise { - if (!isDatabaseEnabled()) { - debugLog('cache', 'skipping stale cache deletion because database is disabled') - return 0 - } - - const pool = await getPool() - if (!pool) { - debugLog('cache', 'skipping stale cache deletion because database pool is unavailable') - return 0 - } +export async function clearUserCache(userAddress: string, version?: string): Promise { + const userAddressHash = getUserAddressCacheKey(userAddress) - try { - const [staleTotalsResult, expiredMissesResult, staleRateLimitsResult] = await Promise.all([ - pool.query(`DELETE FROM holdings_totals WHERE date < NOW() - INTERVAL '366 days'`), - pool.query(`DELETE FROM token_price_misses WHERE expires_at <= NOW()`), - pool.query(`DELETE FROM rate_limits WHERE window_start < NOW() - INTERVAL '1 day'`) - ]) - const deletedCount = - (staleTotalsResult.rowCount ?? 0) + (expiredMissesResult.rowCount ?? 0) + (staleRateLimitsResult.rowCount ?? 0) - console.log(`[Cache] Deleted ${deletedCount} stale cache rows`) - return deletedCount - } catch (error) { - console.error('[Cache] Failed to delete stale cache:', error) - debugError('cache', 'stale cache deletion failed', error) - return 0 - } -} - -export async function clearUserCache(userAddress: string, version?: VaultVersion): Promise { if (!isDatabaseEnabled()) { debugLog('cache', 'skipping user cache clear because database is disabled', { - userAddressHash: getUserAddressCacheKey(userAddress), + userAddressHash, version: version ?? null }) return 0 @@ -455,27 +123,29 @@ export async function clearUserCache(userAddress: string, version?: VaultVersion const pool = await getPool() if (!pool) { debugLog('cache', 'skipping user cache clear because database pool is unavailable', { - userAddressHash: getUserAddressCacheKey(userAddress), + userAddressHash, version: version ?? null }) return 0 } try { - const userAddressHash = getUserAddressCacheKey(userAddress) const result = version - ? await pool.query('DELETE FROM holdings_totals WHERE user_address_hash = $1 AND version = $2', [ - userAddressHash, - version - ]) - : await pool.query('DELETE FROM holdings_totals WHERE user_address_hash = $1', [userAddressHash]) + ? await pool.query( + 'DELETE FROM holdings_totals WHERE user_address_hash = $1 AND version = $2', + [userAddressHash, version], + { disableOnFailure: false } + ) + : await pool.query('DELETE FROM holdings_totals WHERE user_address_hash = $1', [userAddressHash], { + disableOnFailure: false + }) const deletedCount = result.rowCount ?? 0 console.log(`[Cache] Cleared ${deletedCount} cached rows for user ${userAddress}${version ? ` (${version})` : ''}`) return deletedCount } catch (error) { console.error('[Cache] Failed to clear user cache:', error) debugError('cache', 'user cache clear failed', error, { - userAddressHash: getUserAddressCacheKey(userAddress), + userAddressHash, version: version ?? null }) return 0 @@ -519,7 +189,7 @@ export async function invalidateVaults(vaults: VaultIdentifier[]): Promise { diff --git a/api/lib/holdings/services/cow.test.ts b/api/lib/holdings/services/cow.test.ts deleted file mode 100644 index 58f4f6136..000000000 --- a/api/lib/holdings/services/cow.test.ts +++ /dev/null @@ -1,184 +0,0 @@ -import { encodeAbiParameters, encodeEventTopics, parseAbiItem } from 'viem' -import { describe, expect, it } from 'vitest' -import type { TransferEvent, VaultMetadata } from '../types' -import { enrichRawPnlEventsWithCowTradeAcquisitions, type RpcTransactionReceipt } from './cow' -import { buildRawPnlEvents, processRawPnlEvents } from './pnl' - -const USER = '0x2222222222222222222222222222222222222222' -const SOLVER = '0x6bf97afe2d2c790999cded2a8523009eb8a0823f' -const GPV2_SETTLEMENT = '0x9008d19f58aabd9ed0d60971565aa8510560ab41' -const DAI = '0x6b175474e89094c44da98b954eedeac495271d0f' -const COW_VAULT = '0x27b5739e22ad9033bcbf192059122d163b60349d' -const ASSET_TOKEN = '0xfcc5c47be19d06bf83eb04298b026f81069ff65b' -const FAMILY_KEY = `1:${COW_VAULT}` -const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000' -const TRANSFER_EVENT = parseAbiItem('event Transfer(address indexed from, address indexed to, uint256 value)') -const TRADE_EVENT = parseAbiItem( - 'event Trade(address indexed owner, address sellToken, address buyToken, uint256 sellAmount, uint256 buyAmount, uint256 feeAmount, bytes orderUid)' -) - -function createTransferEvent(overrides: Partial): TransferEvent { - return { - id: 'transfer', - vaultAddress: COW_VAULT, - chainId: 1, - blockNumber: 1, - blockTimestamp: 100, - logIndex: 1, - transactionHash: '0xcow', - transactionFrom: SOLVER, - sender: GPV2_SETTLEMENT, - receiver: USER, - value: '500', - ...overrides - } -} - -function createMetadataMap(): Map { - return new Map([ - [ - FAMILY_KEY, - { - address: COW_VAULT, - chainId: 1, - token: { - address: ASSET_TOKEN, - symbol: 'yCRV', - decimals: 18 - }, - decimals: 18 - } - ] - ]) -} - -function createTransferLog( - address: string, - from: string, - to: string, - value: bigint, - logIndex: number -): RpcTransactionReceipt['logs'][number] { - return { - address, - topics: encodeEventTopics({ - abi: [TRANSFER_EVENT], - eventName: 'Transfer', - args: { from, to } - }) as string[], - data: encodeAbiParameters([{ type: 'uint256' }], [value]), - logIndex: `0x${logIndex.toString(16)}` - } -} - -function createTradeLog(buyAmount: bigint): RpcTransactionReceipt['logs'][number] { - return { - address: GPV2_SETTLEMENT, - topics: encodeEventTopics({ - abi: [TRADE_EVENT], - eventName: 'Trade', - args: { owner: USER } - }) as string[], - data: encodeAbiParameters( - [ - { type: 'address', name: 'sellToken' }, - { type: 'address', name: 'buyToken' }, - { type: 'uint256', name: 'sellAmount' }, - { type: 'uint256', name: 'buyAmount' }, - { type: 'uint256', name: 'feeAmount' }, - { type: 'bytes', name: 'orderUid' } - ], - [DAI, COW_VAULT, 250n, buyAmount, 0n, '0x1234'] - ), - logIndex: '0x0' - } -} - -function createReceipt(buyAmount: bigint): RpcTransactionReceipt { - return { - logs: [ - createTradeLog(buyAmount), - createTransferLog(ASSET_TOKEN, GPV2_SETTLEMENT, COW_VAULT, 800n, 1), - createTransferLog(COW_VAULT, ZERO_ADDRESS, GPV2_SETTLEMENT, 1000n, 2), - createTransferLog(COW_VAULT, GPV2_SETTLEMENT, USER, buyAmount, 3) - ] - } -} - -function createRawCowTransferEvents() { - const transferIn = createTransferEvent({ - id: 'cow-transfer-in', - blockNumber: 2, - blockTimestamp: 200, - logIndex: 3, - transactionHash: '0xcow', - value: '500' - }) - const txMint = createTransferEvent({ - id: 'cow-mint', - sender: ZERO_ADDRESS, - receiver: GPV2_SETTLEMENT, - blockNumber: 2, - blockTimestamp: 200, - logIndex: 2, - transactionHash: '0xcow', - value: '1000' - }) - - return buildRawPnlEvents({ - addressEvents: { - deposits: [], - withdrawals: [], - transfersIn: [transferIn], - transfersOut: [] - }, - transactionEvents: { - deposits: [], - withdrawals: [], - transfers: [txMint, transferIn] - } - }) -} - -describe('enrichRawPnlEventsWithCowTradeAcquisitions', () => { - it('adds a tx-scoped synthetic deposit that turns a CoW settlement transfer-in into known basis', async () => { - const enrichedEvents = await enrichRawPnlEventsWithCowTradeAcquisitions(createRawCowTransferEvents(), USER, { - fetchMetadata: async () => createMetadataMap(), - fetchReceipt: async () => createReceipt(500n) - }) - - expect( - enrichedEvents.some( - (event) => - event.kind === 'deposit' && - event.id.startsWith('cow-trade:') && - event.scopes.address === false && - event.scopes.tx === true && - event.assets === 400n && - event.shares === 500n - ) - ).toBe(true) - - const ledger = processRawPnlEvents(enrichedEvents, USER).get(FAMILY_KEY) - - expect(ledger?.vaultLots).toEqual([{ shares: 500n, costBasis: 400n, acquiredAt: 200 }]) - expect(ledger?.unknownCostBasisTransferInCount).toBe(0) - expect(ledger?.totalDepositedAssets).toBe(400n) - expect(ledger?.eventCounts.underlyingDeposits).toBe(1) - }) - - it('skips enrichment when the CoW trade buy amount does not match the received share transfer', async () => { - const enrichedEvents = await enrichRawPnlEventsWithCowTradeAcquisitions(createRawCowTransferEvents(), USER, { - fetchMetadata: async () => createMetadataMap(), - fetchReceipt: async () => createReceipt(400n) - }) - - expect(enrichedEvents.some((event) => event.kind === 'deposit' && event.id.startsWith('cow-trade:'))).toBe(false) - - const ledger = processRawPnlEvents(enrichedEvents, USER).get(FAMILY_KEY) - - expect(ledger?.vaultLots).toEqual([{ shares: 500n, costBasis: null, acquiredAt: 200 }]) - expect(ledger?.unknownCostBasisTransferInCount).toBe(1) - expect(ledger?.unknownCostBasisTransferInShares).toBe(500n) - }) -}) diff --git a/api/lib/holdings/services/cow.ts b/api/lib/holdings/services/cow.ts deleted file mode 100644 index 6e6b05978..000000000 --- a/api/lib/holdings/services/cow.ts +++ /dev/null @@ -1,413 +0,0 @@ -import { decodeEventLog, type Hex, parseAbiItem } from 'viem' -import { config } from '../config' -import type { VaultMetadata } from '../types' -import { debugError, debugLog } from './debug' -import { lowerCaseAddress, toVaultKey, ZERO, ZERO_ADDRESS } from './pnlShared' -import type { TRawPnlEvent } from './pnlTypes' -import { fetchMultipleVaultsMetadata } from './vaults' - -const MAINNET_CHAIN_ID = 1 -const GPV2_SETTLEMENT_ADDRESS = '0x9008d19f58aabd9ed0d60971565aa8510560ab41' -const DEFAULT_TIMEOUT_MS = 4_000 -const DEFAULT_MAX_RETRIES = 1 -const TRANSFER_EVENT = parseAbiItem('event Transfer(address indexed from, address indexed to, uint256 value)') -const TRADE_EVENT = parseAbiItem( - 'event Trade(address indexed owner, address sellToken, address buyToken, uint256 sellAmount, uint256 buyAmount, uint256 feeAmount, bytes orderUid)' -) - -type TFetchMetadataFn = typeof fetchMultipleVaultsMetadata -type TFetchReceiptFn = (transactionHash: string) => Promise - -type TCowTradeCandidate = { - chainId: number - familyVaultAddress: string - transactionHash: string - transactionFrom: string - blockNumber: number - blockTimestamp: number - transferInShares: bigint -} - -type TDecodedTransferLog = { - address: string - from: string - to: string - value: bigint -} - -type TDecodedTradeLog = { - owner: string - sellToken: string - buyToken: string - sellAmount: bigint - buyAmount: bigint - logIndex: number -} - -export interface RpcReceiptLog { - address: string - data: string - topics: string[] - logIndex: string | null -} - -export interface RpcTransactionReceipt { - logs: RpcReceiptLog[] | null -} - -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 groupEventsByTransaction(events: TRawPnlEvent[]): TRawPnlEvent[][] { - return Array.from( - events.reduce>((groups, event) => { - const transactionKey = `${event.chainId}:${event.transactionHash}` - const nextEvents = [...(groups.get(transactionKey) ?? []), event] - groups.set(transactionKey, nextEvents) - return groups - }, new Map()) - ).map(([_key, txEvents]) => [...txEvents].sort(compareRawEvents)) -} - -function groupTransactionEventsByFamily(txEvents: TRawPnlEvent[]): TRawPnlEvent[][] { - return Array.from( - txEvents.reduce>((groups, event) => { - const familyKey = toVaultKey(event.chainId, event.familyVaultAddress) - const nextEvents = [...(groups.get(familyKey) ?? []), event] - groups.set(familyKey, nextEvents) - return groups - }, new Map()) - ).map(([_key, familyEvents]) => [...familyEvents].sort(compareRawEvents)) -} - -function sumValues(values: bigint[]): bigint { - return values.reduce((total, value) => total + value, ZERO) -} - -function parseHexInteger(value: string | null): number { - return value === null ? 0 : Number.parseInt(value, 16) -} - -function decodeTransferLog(log: RpcReceiptLog): TDecodedTransferLog | null { - try { - const decoded = decodeEventLog({ - abi: [TRANSFER_EVENT], - data: log.data as Hex, - topics: log.topics as Hex[] - }) - const args = decoded.args as { - from: string - to: string - value: bigint - } - - return { - address: lowerCaseAddress(log.address), - from: lowerCaseAddress(args.from), - to: lowerCaseAddress(args.to), - value: args.value - } - } catch { - return null - } -} - -function decodeTradeLog(log: RpcReceiptLog): TDecodedTradeLog | null { - try { - const decoded = decodeEventLog({ - abi: [TRADE_EVENT], - data: log.data as Hex, - topics: log.topics as Hex[] - }) - const args = decoded.args as { - owner: string - sellToken: string - buyToken: string - sellAmount: bigint - buyAmount: bigint - } - - return { - owner: lowerCaseAddress(args.owner), - sellToken: lowerCaseAddress(args.sellToken), - buyToken: lowerCaseAddress(args.buyToken), - sellAmount: args.sellAmount, - buyAmount: args.buyAmount, - logIndex: parseHexInteger(log.logIndex) - } - } catch { - return null - } -} - -async function fetchTransactionReceipt(transactionHash: string, attempt = 0): Promise { - try { - const response = await fetch(config.ethereumRpcUrl, { - method: 'POST', - headers: { - 'content-type': 'application/json' - }, - body: JSON.stringify({ - jsonrpc: '2.0', - id: 1, - method: 'eth_getTransactionReceipt', - params: [transactionHash] - }), - signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS) - }) - - if (!response.ok) { - throw new Error(`RPC receipt request failed: ${response.status}`) - } - - const payload = (await response.json()) as { - result: RpcTransactionReceipt | null - error?: { - message?: string - } - } - - if (payload.error) { - throw new Error(payload.error.message ?? 'RPC receipt request returned an error') - } - - return payload.result - } catch (error) { - if (attempt >= DEFAULT_MAX_RETRIES) { - debugError('cow', 'failed to fetch CoW settlement receipt', error, { transactionHash, attempt: attempt + 1 }) - return null - } - - debugError('cow', 'retrying CoW settlement receipt fetch', error, { - transactionHash, - nextAttempt: attempt + 2 - }) - return fetchTransactionReceipt(transactionHash, attempt + 1) - } -} - -function getCowTradeCandidates(rawEvents: TRawPnlEvent[], userAddress: string): TCowTradeCandidate[] { - return groupEventsByTransaction(rawEvents).flatMap((txEvents) => - groupTransactionEventsByFamily(txEvents).flatMap((familyEvents) => { - const familyVaultAddress = familyEvents[0]?.familyVaultAddress - const chainId = familyEvents[0]?.chainId - const transactionHash = familyEvents[0]?.transactionHash - const transactionFrom = familyEvents[0]?.transactionFrom - const blockNumber = familyEvents[0]?.blockNumber ?? 0 - const blockTimestamp = familyEvents[0]?.blockTimestamp ?? 0 - const hasDirectFamilyDeposit = familyEvents.some( - (event) => event.kind === 'deposit' && event.vaultAddress === familyVaultAddress - ) - const settlementTransfersIn = familyEvents.filter( - (event): event is Extract => - event.kind === 'transfer' && - event.scopes.address && - event.vaultAddress === familyVaultAddress && - event.sender === GPV2_SETTLEMENT_ADDRESS && - event.receiver === userAddress - ) - - if ( - chainId !== MAINNET_CHAIN_ID || - !familyVaultAddress || - !transactionHash || - !transactionFrom || - hasDirectFamilyDeposit || - settlementTransfersIn.length === 0 - ) { - return [] - } - - return [ - { - chainId, - familyVaultAddress, - transactionHash, - transactionFrom, - blockNumber, - blockTimestamp, - transferInShares: sumValues(settlementTransfersIn.map((event) => event.shares)) - } - ] - }) - ) -} - -function buildSyntheticCowTradeDeposit( - candidate: TCowTradeCandidate, - metadata: VaultMetadata, - receipt: RpcTransactionReceipt, - userAddress: string -): Extract | null { - const decodedTrades = (receipt.logs ?? []) - .filter((log) => lowerCaseAddress(log.address) === GPV2_SETTLEMENT_ADDRESS) - .map(decodeTradeLog) - .filter( - (trade): trade is TDecodedTradeLog => - trade !== null && trade.owner === userAddress && trade.buyToken === candidate.familyVaultAddress - ) - - const boughtShares = sumValues(decodedTrades.map((trade) => trade.buyAmount)) - - if (boughtShares === ZERO || boughtShares !== candidate.transferInShares) { - return null - } - - const decodedTransfers = (receipt.logs ?? []) - .map(decodeTransferLog) - .filter((log): log is TDecodedTransferLog => log !== null) - const mintedShares = sumValues( - decodedTransfers - .filter( - (log) => - log.address === candidate.familyVaultAddress && - log.from === ZERO_ADDRESS && - log.to === GPV2_SETTLEMENT_ADDRESS - ) - .map((log) => log.value) - ) - const depositedAssets = sumValues( - decodedTransfers - .filter( - (log) => log.address === lowerCaseAddress(metadata.token.address) && log.to === candidate.familyVaultAddress - ) - .map((log) => log.value) - ) - - if (mintedShares === ZERO || depositedAssets === ZERO || boughtShares > mintedShares) { - return null - } - - const assets = (depositedAssets * boughtShares) / mintedShares - - if (assets === ZERO) { - return null - } - - const sellAmount = sumValues(decodedTrades.map((trade) => trade.sellAmount)) - const logIndex = decodedTrades.reduce( - (minimum, trade) => (trade.logIndex < minimum ? trade.logIndex : minimum), - decodedTrades[0]?.logIndex ?? 0 - ) - - debugLog('cow', 'synthesized CoW settlement acquisition', { - transactionHash: candidate.transactionHash, - familyVaultAddress: candidate.familyVaultAddress, - owner: userAddress, - sellToken: decodedTrades[0]?.sellToken ?? null, - sellAmount: sellAmount.toString(), - boughtShares: boughtShares.toString(), - syntheticAssets: assets.toString(), - totalMintedShares: mintedShares.toString(), - totalDepositedAssets: depositedAssets.toString() - }) - - return { - kind: 'deposit', - id: `cow-trade:${candidate.chainId}:${candidate.transactionHash}:${candidate.familyVaultAddress}:${userAddress}`, - chainId: candidate.chainId, - vaultAddress: candidate.familyVaultAddress, - familyVaultAddress: candidate.familyVaultAddress, - isStakingVault: false, - blockNumber: candidate.blockNumber, - blockTimestamp: candidate.blockTimestamp, - logIndex, - transactionHash: candidate.transactionHash, - transactionFrom: candidate.transactionFrom, - owner: userAddress, - sender: GPV2_SETTLEMENT_ADDRESS, - shares: boughtShares, - assets, - scopes: { - address: false, - tx: true - } - } -} - -export async function enrichRawPnlEventsWithCowTradeAcquisitions( - rawEvents: TRawPnlEvent[], - userAddress: string, - dependencies?: { - fetchMetadata?: TFetchMetadataFn - fetchReceipt?: TFetchReceiptFn - } -): Promise { - const userAddressLower = lowerCaseAddress(userAddress) - const candidates = getCowTradeCandidates(rawEvents, userAddressLower) - - if (candidates.length === 0) { - return rawEvents - } - - try { - const fetchMetadata = dependencies?.fetchMetadata ?? fetchMultipleVaultsMetadata - const metadataRequests = Array.from( - candidates.reduce>((requests, candidate) => { - requests.set(toVaultKey(candidate.chainId, candidate.familyVaultAddress), { - chainId: candidate.chainId, - vaultAddress: candidate.familyVaultAddress - }) - return requests - }, new Map()) - ).map(([_key, request]) => request) - const metadataByVaultKey = await fetchMetadata(metadataRequests) - const candidatesWithMetadata = candidates.flatMap((candidate) => { - const metadata = metadataByVaultKey.get(toVaultKey(candidate.chainId, candidate.familyVaultAddress)) - return metadata ? [{ ...candidate, metadata }] : [] - }) - - if (candidatesWithMetadata.length === 0) { - return rawEvents - } - - const fetchReceipt = dependencies?.fetchReceipt ?? fetchTransactionReceipt - const receiptEntries = await Promise.all( - Array.from(new Set(candidatesWithMetadata.map((candidate) => candidate.transactionHash.toLowerCase()))).map( - async (transactionHash) => [transactionHash, await fetchReceipt(transactionHash)] as const - ) - ) - const receiptsByTransactionHash = receiptEntries.reduce>((receipts, entry) => { - const [transactionHash, receipt] = entry - - if (receipt !== null) { - receipts.set(transactionHash, receipt) - } - - return receipts - }, new Map()) - const syntheticDeposits = candidatesWithMetadata.flatMap((candidate) => { - const receipt = receiptsByTransactionHash.get(candidate.transactionHash.toLowerCase()) - - if (!receipt) { - return [] - } - - const syntheticDeposit = buildSyntheticCowTradeDeposit(candidate, candidate.metadata, receipt, userAddressLower) - - return syntheticDeposit ? [syntheticDeposit] : [] - }) - - if (syntheticDeposits.length === 0) { - return rawEvents - } - - debugLog('cow', 'completed CoW settlement enrichment', { - candidates: candidatesWithMetadata.length, - synthesizedDeposits: syntheticDeposits.length - }) - - return [...rawEvents, ...syntheticDeposits].sort(compareRawEvents) - } catch (error) { - debugError('cow', 'failed CoW settlement enrichment, continuing with raw events', error, { - candidateCount: candidates.length - }) - return rawEvents - } -} diff --git a/api/lib/holdings/services/debug.ts b/api/lib/holdings/services/debug.ts index eb045f66c..54cc747f6 100644 --- a/api/lib/holdings/services/debug.ts +++ b/api/lib/holdings/services/debug.ts @@ -1,14 +1,16 @@ import { AsyncLocalStorage } from 'node:async_hooks' +import { appendHoldingsProgressLog, updateHoldingsProgress } from './progress' export interface HoldingsDebugContext { enabled: boolean requestId: string - route: 'history' | 'breakdown' | 'pnl' + 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() @@ -31,13 +33,14 @@ export function isHoldingsDebugRequested(debugValue?: string | null): boolean { } export function createHoldingsDebugContext( - route: 'history' | 'breakdown' | 'pnl', + route: 'history' | 'breakdown' | 'protocol-return-history', address: string, enabled: boolean, options?: { lotsEnabled?: boolean vaultFilter?: string | null txFilter?: string | null + progressId?: string | null } ): HoldingsDebugContext { return { @@ -48,7 +51,8 @@ export function createHoldingsDebugContext( startedAt: Date.now(), lotsEnabled: options?.lotsEnabled ?? false, vaultFilter: options?.vaultFilter?.toLowerCase() ?? null, - txFilter: options?.txFilter?.toLowerCase() ?? null + txFilter: options?.txFilter?.toLowerCase() ?? null, + progressId: options?.progressId ?? null } } @@ -77,14 +81,25 @@ export function getHoldingsDebugFilters(): { export function debugLog(scope: string, message: string, payload?: Record): void { const context = getHoldingsDebugContext() - if (!context?.enabled) { + 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() diff --git a/api/lib/holdings/services/defillama.test.ts b/api/lib/holdings/services/defillama.test.ts index 228e51844..228586643 100644 --- a/api/lib/holdings/services/defillama.test.ts +++ b/api/lib/holdings/services/defillama.test.ts @@ -1,28 +1,14 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import type { DefiLlamaBatchResponse } from '../types' -import { getCachedPriceMisses, getCachedPrices, saveCachedPriceMisses, saveCachedPrices } from './cache' import { fetchHistoricalPrices, fetchHistoricalPricesForTokenTimestamps, getChainPrefix, + getHistoricalPriceFetchFailedBatches, getPriceAtTimestamp, parseDefiLlamaResponse } from './defillama' -vi.mock('./cache', () => { - const getCachedPriceMisses = vi.fn(async () => new Map()) - const getCachedPrices = vi.fn(async () => new Map()) - - return { - getCachedPriceMisses, - getCachedPriceMissesForTokenTimestamps: getCachedPriceMisses, - getCachedPrices, - getCachedPricesForTokenTimestamps: getCachedPrices, - saveCachedPriceMisses: vi.fn(async () => {}), - saveCachedPrices: vi.fn(async () => {}) - } -}) - function createBatchResponse(response: DefiLlamaBatchResponse): Response { return new Response(JSON.stringify(response), { status: 200, @@ -34,10 +20,6 @@ afterEach(() => { vi.restoreAllMocks() vi.unstubAllGlobals() vi.unstubAllEnvs() - vi.mocked(getCachedPriceMisses).mockResolvedValue(new Map()) - vi.mocked(getCachedPrices).mockResolvedValue(new Map()) - vi.mocked(saveCachedPriceMisses).mockResolvedValue() - vi.mocked(saveCachedPrices).mockResolvedValue() }) describe('parseDefiLlamaResponse', () => { @@ -97,6 +79,280 @@ describe('parseDefiLlamaResponse', () => { 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], @@ -147,26 +403,55 @@ describe('parseDefiLlamaResponse', () => { ).rejects.toThrow('Failed to fetch token prices from DefiLlama') }) - it('fetches only missing token-timestamp pairs instead of the union across missing tokens', async () => { + 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' - vi.mocked(getCachedPrices).mockResolvedValue( - new Map([ - [usdcKey, new Map([[1700000000, 1]])], - [daiKey, new Map([[1700003600, 1]])] - ]) - ) const fetchStub = vi.fn().mockResolvedValue( createBatchResponse({ coins: { [usdcKey]: { symbol: 'USDC', - prices: [{ timestamp: 1700003600, price: 1.001, confidence: 0.99 }] + 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 }] + prices: [ + { timestamp: 1700000000, price: 0.999, confidence: 0.99 }, + { timestamp: 1700003600, price: 1, confidence: 0.99 } + ] } } }) @@ -191,8 +476,8 @@ describe('parseDefiLlamaResponse', () => { > expect(coinsParam).toEqual({ - [usdcKey]: [1700003600], - [daiKey]: [1700000000] + [usdcKey]: [1700000000, 1700003600], + [daiKey]: [1700000000, 1700003600] }) expect(prices.get(usdcKey)?.get(1700000000)).toBe(1) expect(prices.get(usdcKey)?.get(1700003600)).toBe(1.001) @@ -200,22 +485,6 @@ describe('parseDefiLlamaResponse', () => { expect(prices.get(daiKey)?.get(1700003600)).toBe(1) }) - it('skips token-timestamp pairs already cached as misses', async () => { - const usdcKey = 'ethereum:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48' - vi.mocked(getCachedPriceMisses).mockResolvedValue(new Map([[usdcKey, new Set([1700000000])]])) - const fetchStub = vi.fn() - - vi.stubGlobal('fetch', fetchStub) - - const prices = await fetchHistoricalPrices( - [{ chainId: 1, address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' }], - [1700000000] - ) - - expect(fetchStub).not.toHaveBeenCalled() - expect(prices.get(usdcKey)?.size).toBe(0) - }) - it('merges multiple timestamp slices for the same token into a single batch request', async () => { const usdcKey = 'ethereum:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48' const timestamps = [ @@ -557,7 +826,7 @@ describe('parseDefiLlamaResponse', () => { }) }) - it('only caches shifted prices for requested timestamps that have a historical quote available', async () => { + 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( @@ -583,13 +852,9 @@ describe('parseDefiLlamaResponse', () => { expect(prices.get(usdcKey)?.get(1700000000)).toBeUndefined() expect(prices.get(usdcKey)?.get(1700003600)).toBe(0.999) - expect(vi.mocked(saveCachedPrices)).toHaveBeenCalledWith([ - { tokenKey: usdcKey, timestamp: 1700003600, price: 0.999 } - ]) - expect(vi.mocked(saveCachedPriceMisses)).toHaveBeenCalledWith([{ tokenKey: usdcKey, timestamp: 1700000000 }]) }) - it('caches exact token-timestamp misses when DefiLlama returns no prices for a token', async () => { + 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({ @@ -605,13 +870,9 @@ describe('parseDefiLlamaResponse', () => { ) expect(prices.get(usdcKey)?.size).toBe(0) - expect(vi.mocked(saveCachedPriceMisses)).toHaveBeenCalledWith([ - { tokenKey: usdcKey, timestamp: 1700000000 }, - { tokenKey: usdcKey, timestamp: 1700003600 } - ]) }) - it('does not read or write cache for uncached timestamp requests', async () => { + it('fetches all requested timestamps without local price cache filtering', async () => { const tokenAddress = '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48' const tokenKey = `ethereum:${tokenAddress}` const fetchStub = vi.fn().mockImplementation(() => @@ -636,19 +897,24 @@ describe('parseDefiLlamaResponse', () => { { chainId: 1, address: tokenAddress, - timestamps: [1700000000, 1700003600], - uncachedTimestamps: [1700003600] + timestamps: [1700000000, 1700003600] } ]) - expect(getCachedPrices).toHaveBeenCalledWith([{ tokenKey, timestamps: [1700000000] }]) - expect(getCachedPriceMisses).toHaveBeenCalledWith([{ tokenKey, timestamps: [1700000000] }]) - expect(saveCachedPrices).toHaveBeenCalledWith([{ tokenKey, timestamp: 1700000000, price: 1 }]) - expect(saveCachedPriceMisses).not.toHaveBeenCalled() + 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('records misses for timestamps that only have future quotes available', async () => { + it('ignores future quotes outside strict timestamp tolerance', async () => { const usdcKey = 'ethereum:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48' const fetchStub = vi.fn().mockResolvedValue( createBatchResponse({ @@ -670,9 +936,35 @@ describe('parseDefiLlamaResponse', () => { expect(prices.get(usdcKey)?.get(1700000000)).toBeUndefined() expect(prices.get(usdcKey)?.get(1700003600)).toBe(1.001) - expect(vi.mocked(saveCachedPrices)).toHaveBeenCalledWith([ - { tokenKey: usdcKey, timestamp: 1700003600, price: 1.001 } - ]) - expect(vi.mocked(saveCachedPriceMisses)).toHaveBeenCalledWith([{ tokenKey: usdcKey, timestamp: 1700000000 }]) + }) + + 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 index d69638ca1..e51f0457e 100644 --- a/api/lib/holdings/services/defillama.ts +++ b/api/lib/holdings/services/defillama.ts @@ -1,13 +1,5 @@ -import { config } from '../config' +import { holdingsConfig } from '../config' import { type DefiLlamaBatchResponse, SUPPORTED_CHAINS } from '../types' -import { - type CachedPrice, - type CachedPriceMiss, - getCachedPriceMissesForTokenTimestamps, - getCachedPricesForTokenTimestamps, - saveCachedPriceMisses, - saveCachedPrices -} from './cache' import { debugError, debugLog } from './debug' type TDefiLlamaError = Error & { @@ -15,6 +7,12 @@ type TDefiLlamaError = Error & { status?: number } +type THistoricalPriceProvider = 'defillama' | 'yearn-prices' +type THistoricalPriceProviderConfig = { + provider: THistoricalPriceProvider + label: string +} + const RETRYABLE_ERROR_CODES = new Set([ 'ECONNRESET', 'ECONNREFUSED', @@ -29,19 +27,21 @@ const RETRYABLE_ERROR_CODES = new Set([ 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 TMissingPriceFetchGroup = { - label: 'cacheable' | 'uncached' - cacheResults: boolean - coins: TCoinRequest[] -} type TDefiLlamaFetchTuning = { + provider: THistoricalPriceProvider useProApi: boolean timeoutMs: number maxRetries: number @@ -58,14 +58,24 @@ type TDefiLlamaFetchTuning = { type TDefiLlamaBatchRequest = { url: string init: RequestInit - variant: 'free_get' | 'pro_get' + variant: 'free_get' | 'pro_get' | 'yearn_prices_get' | 'yearn_prices_range_get' } export type THistoricalPriceRequest = { chainId: number address: string timestamps: number[] - uncachedTimestamps?: 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 { @@ -73,6 +83,75 @@ export function getChainPrefix(chainId: number): string { 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)) } @@ -93,6 +172,22 @@ function isRetryableError(error: unknown): boolean { ) } +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) @@ -103,6 +198,10 @@ 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) => { @@ -181,7 +280,12 @@ function buildRequestBatches( const nextBatchUrlLength = tuning.maxRequestUrlLength === null ? 0 - : (tuning.useProApi ? buildProBatchHistoricalGetUrl(nextBatch) : buildBatchHistoricalUrl(nextBatch)).length + : (tuning.provider === 'yearn-prices' + ? buildYearnPricesRequest(nextBatch).url + : tuning.useProApi + ? buildProBatchHistoricalGetUrl(nextBatch) + : buildBatchHistoricalUrl(nextBatch) + ).length if ( currentBatch.length > 0 && @@ -240,17 +344,71 @@ function getPriceAtTimestampWithinTolerance( 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> -): CachedPrice[] { + 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 = getPriceAtTimestampWithinTolerance(fetchedPriceMap, timestamp) + const matchedPrice = matchPriceAtTimestamp(fetchedPriceMap, timestamp) return matchedPrice === null ? null : { @@ -259,108 +417,93 @@ function materializeRequestedPrices( price: matchedPrice.price } }) - .filter((entry): entry is CachedPrice => entry !== null && entry.price > 0) + .filter((entry): entry is TMaterializedPrice => entry !== null && entry.price > 0) }) } -function materializeRequestedPriceMisses( +function buildCoinsParam( coins: TCoinRequest[], - fetchedPrices: Map> -): CachedPriceMiss[] { - return coins.flatMap((coin) => { - const tokenKey = `${coin.chain}:${coin.address.toLowerCase()}` - const fetchedPriceMap = fetchedPrices.get(tokenKey) ?? new Map() - - if (!fetchedPriceMap || fetchedPriceMap.size === 0) { - return coin.timestamps.map((timestamp) => ({ - tokenKey, - timestamp - })) - } - - return coin.timestamps - .filter((timestamp) => getPriceAtTimestampWithinTolerance(fetchedPriceMap, timestamp) === null) - .map((timestamp) => ({ - tokenKey, - timestamp - })) - }) -} + options: { normalizeTimestampsToDayEnd?: boolean } = {} +): Record { + return coins.reduce>((accumulator, coin) => { + const timestamps = options.normalizeTimestampsToDayEnd + ? getNormalizedUtcDayEndTimestamps(coin.timestamps) + : coin.timestamps -function toTimestampCacheKey(tokenKey: string, timestamp: number): string { - return `${tokenKey}:${timestamp}` + accumulator[`${coin.chain}:${coin.address.toLowerCase()}`] = timestamps + return accumulator + }, {}) } -function filterCacheableTimestamps( - timestamps: number[], - uncachedTimestampKeys: Set, - tokenKey: string -): number[] { - return timestamps.filter((timestamp) => !uncachedTimestampKeys.has(toTimestampCacheKey(tokenKey, timestamp))) -} +function buildRangeCoinsParam(coins: TCoinRequest[]): Record { + return coins.reduce>((accumulator, coin) => { + const range = getContiguousUtcDayEndRange(coin.timestamps) -function splitMissingCoinsByCacheability( - coins: TCoinRequest[], - missingByToken: Map, - uncachedTimestampKeys: Set -): TMissingPriceFetchGroup[] { - const groups = coins.reduce>( - (result, coin) => { - const tokenKey = `${coin.chain}:${coin.address.toLowerCase()}` - const missingTimestamps = missingByToken.get(tokenKey) ?? [] - const cacheableTimestamps = missingTimestamps.filter( - (timestamp) => !uncachedTimestampKeys.has(toTimestampCacheKey(tokenKey, timestamp)) - ) - const uncachedTimestamps = missingTimestamps.filter((timestamp) => - uncachedTimestampKeys.has(toTimestampCacheKey(tokenKey, timestamp)) - ) - - if (cacheableTimestamps.length > 0) { - result.cacheable.coins.push({ - chain: coin.chain, - address: coin.address, - timestamps: cacheableTimestamps - }) - } - - if (uncachedTimestamps.length > 0) { - result.uncached.coins.push({ - chain: coin.chain, - address: coin.address, - timestamps: uncachedTimestamps - }) - } - - return result - }, - { - cacheable: { label: 'cacheable', cacheResults: true, coins: [] }, - uncached: { label: 'uncached', cacheResults: false, coins: [] } + if (range !== null) { + accumulator[`${coin.chain}:${coin.address.toLowerCase()}`] = range } - ) - - return [groups.cacheable, groups.uncached].filter((group) => group.coins.length > 0) -} -function buildCoinsParam(coins: TCoinRequest[]): Record { - return coins.reduce>((accumulator, coin) => { - accumulator[`${coin.chain}:${coin.address.toLowerCase()}`] = coin.timestamps return accumulator }, {}) } export function buildBatchHistoricalUrl(coins: TCoinRequest[]): string { const encodedCoins = encodeURIComponent(JSON.stringify(buildCoinsParam(coins))) - return `${config.defillamaBaseUrl}/batchHistorical?coins=${encodedCoins}` + return `${holdingsConfig.defillamaBaseUrl}/batchHistorical?coins=${encodedCoins}` } function buildProBatchHistoricalGetUrl(coins: TCoinRequest[]): string { const encodedCoins = encodeURIComponent(JSON.stringify(buildCoinsParam(coins))) - return `${config.defillamaProBaseUrl}/${config.defillamaApiKey}/coins/batchHistorical?coins=${encodedCoins}` + 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 buildBatchHistoricalRequests(coins: TCoinRequest[]): TDefiLlamaBatchRequest[] { - if (config.defillamaApiKey.length === 0) { +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), @@ -404,23 +547,27 @@ function buildBatchDebugSummary( lastToken: string | null } { const firstCoin = coinBatch[0] - const lastCoin = coinBatch.at(-1) + const lastCoin = coinBatch.length > 0 ? coinBatch[coinBatch.length - 1] : undefined return { firstTimestamp: uniqueTimestamps[0] ?? null, - lastTimestamp: uniqueTimestamps.at(-1) ?? 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 status = - typeof (error as Partial)?.status === 'number' ? (error as Partial).status : null + 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 { @@ -447,9 +594,27 @@ function splitCoinBatch( } } -function getDefiLlamaFetchTuning(): TDefiLlamaFetchTuning { - if (config.defillamaApiKey.length > 0) { +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, @@ -465,6 +630,7 @@ function getDefiLlamaFetchTuning(): TDefiLlamaFetchTuning { } return { + provider: 'defillama', useProApi: false, timeoutMs: DEFAULT_TIMEOUT_MS, maxRetries: DEFAULT_MAX_RETRIES, @@ -501,15 +667,16 @@ async function fetchBatch( ): Promise>> { const uniqueTimestamps = [...new Set(coinBatch.flatMap((coin) => coin.timestamps))].sort((a, b) => a - b) const requestedPricePoints = countRequestedPricePoints(coinBatch) - const requests = buildBatchHistoricalRequests(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('defillama', 'fetching price batch', { + debugLog('prices', 'fetching price batch', { attempt: attempt + 1, + provider: tuning.provider, tokenCount: coinBatch.length, timestampCount: uniqueTimestamps.length, pricePointCount: requestedPricePoints, @@ -543,12 +710,13 @@ async function fetchBatch( const data = (await response.json()) as DefiLlamaBatchResponse return parseDefiLlamaResponse(data, uniqueTimestamps) } catch (error) { - if ((request.variant === 'pro_get' || request.variant === 'free_get') && isSplittableGetError(error)) { + if (shouldSplitBatchAfterRequestError(error, tuning)) { const splitBatch = splitCoinBatch(coinBatch) if (splitBatch !== null) { - debugError('defillama', 'splitting price batch after get request failed', error, { + debugError('prices', 'splitting price batch after request failed', error, { attempt: attempt + 1, + provider: tuning.provider, tokenCount: coinBatch.length, timestampCount: uniqueTimestamps.length, pricePointCount: requestedPricePoints, @@ -568,8 +736,9 @@ async function fetchBatch( } if (requestIndex < requests.length - 1) { - debugError('defillama', 'price batch request variant failed', error, { + debugError('prices', 'price batch request variant failed', error, { attempt: attempt + 1, + provider: tuning.provider, tokenCount: coinBatch.length, timestampCount: uniqueTimestamps.length, pricePointCount: requestedPricePoints, @@ -592,8 +761,9 @@ async function fetchBatch( throw new Error('DefiLlama batch request resolved without a response') } - debugLog('defillama', 'fetched price batch', { + debugLog('prices', 'fetched price batch', { attempt: attempt + 1, + provider: tuning.provider, tokenCount: coinBatch.length, timestampCount: uniqueTimestamps.length, pricePointCount: requestedPricePoints, @@ -606,8 +776,9 @@ async function fetchBatch( return parsed } catch (error) { if (attempt >= tuning.maxRetries || !isRetryableError(error)) { - debugError('defillama', 'price batch failed', error, { + debugError('prices', 'price batch failed', error, { attempt: attempt + 1, + provider: tuning.provider, tokenCount: coinBatch.length, timestampCount: uniqueTimestamps.length, pricePointCount: requestedPricePoints, @@ -617,8 +788,9 @@ async function fetchBatch( throw error } - debugError('defillama', 'retrying price batch', error, { + debugError('prices', 'retrying price batch', error, { nextAttempt: attempt + 2, + provider: tuning.provider, tokenCount: coinBatch.length, timestampCount: uniqueTimestamps.length, pricePointCount: requestedPricePoints, @@ -631,16 +803,16 @@ async function fetchBatch( } export async function fetchHistoricalPricesForTokenTimestamps( - requests: THistoricalPriceRequest[] + requests: THistoricalPriceRequest[], + options: THistoricalPriceFetchOptions = {} ): Promise>> { - const tuning = getDefiLlamaFetchTuning() - const uncachedTimestampKeys = requests.reduce>((uncachedKeys, request) => { - const tokenKey = `${getChainPrefix(request.chainId)}:${request.address.toLowerCase()}` - request.uncachedTimestamps?.forEach((timestamp) => { - uncachedKeys.add(toTimestampCacheKey(tokenKey, timestamp)) - }) - return uncachedKeys - }, new Set()) + 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) => ({ @@ -654,10 +826,12 @@ export async function fetchHistoricalPricesForTokenTimestamps( const requestedTimestamps = [...new Set(coins.flatMap((coin) => coin.timestamps))].sort((a, b) => a - b) const requestedPricePoints = countRequestedPricePoints(coins) - debugLog('defillama', 'starting historical price fetch', { + debugLog('prices', 'starting historical price fetch', { + provider: providerConfig.provider, tokens: tokenKeys.length, timestamps: requestedTimestamps.length, pricePointCount: requestedPricePoints, + resolution, useProApi: tuning.useProApi, parallelRequests: tuning.parallelRequests }) @@ -671,115 +845,71 @@ export async function fetchHistoricalPricesForTokenTimestamps( return result } - const cacheableLookups = coins - .map((coin) => { - const tokenKey = `${coin.chain}:${coin.address.toLowerCase()}` - return { - tokenKey, - timestamps: filterCacheableTimestamps(coin.timestamps, uncachedTimestampKeys, tokenKey) - } - }) - .filter((lookup) => lookup.timestamps.length > 0) - - const [cachedPrices, cachedPriceMisses] = await Promise.all([ - getCachedPricesForTokenTimestamps(cacheableLookups), - getCachedPriceMissesForTokenTimestamps(cacheableLookups) - ]) - const cachedPricePoints = countPricePoints(cachedPrices) - debugLog('defillama', 'loaded cached price points', { - cachedPoints: cachedPricePoints - }) - const missingByToken = coins.reduce>((missing, coin) => { - const tokenKey = `${coin.chain}:${coin.address.toLowerCase()}` - const cachedForToken = cachedPrices.get(tokenKey) - const cachedMissesForToken = cachedPriceMisses.get(tokenKey) - const missingTimestamps = coin.timestamps.filter((timestamp) => { - if (cachedForToken?.has(timestamp)) { - result.get(tokenKey)!.set(timestamp, cachedForToken.get(timestamp)!) - return false - } - - if (cachedMissesForToken?.has(timestamp)) { - return false - } - - return true - }) - - if (missingTimestamps.length > 0) { - missing.set(tokenKey, missingTimestamps) - } - - return missing - }, new Map()) - - if (missingByToken.size === 0) { - debugLog('defillama', 'historical prices fully satisfied by cache', { - tokens: tokenKeys.length, - timestamps: requestedTimestamps.length, - pricePointCount: requestedPricePoints - }) - return result - } - - const newPrices: CachedPrice[] = [] - const newPriceMisses: CachedPriceMiss[] = [] - const fetchStats = { successfulBatches: 0 } - const missingFetchGroups = splitMissingCoinsByCacheability(coins, missingByToken, uncachedTimestampKeys) - const fetchMissingPriceGroup = async (fetchGroup: TMissingPriceFetchGroup): Promise => { - const tokenRequests = buildTokenRequests(fetchGroup.coins, tuning.timestampBatchSize) - const batches = buildRequestBatches(tokenRequests, tuning) - const batchGroups = chunkItems(batches, tuning.parallelRequests) - const allMissingTimestamps = [...new Set(fetchGroup.coins.flatMap((coin) => coin.timestamps))].sort((a, b) => a - b) - const missingPricePoints = countRequestedPricePoints(fetchGroup.coins) - - debugLog('defillama', 'prepared price fetch batches', { - fetchGroup: fetchGroup.label, - cacheResults: fetchGroup.cacheResults, - tokensToFetch: fetchGroup.coins.length, - missingTokens: fetchGroup.coins.length, - uniqueTimestamps: allMissingTimestamps.length, - missingPricePoints, + 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: tuning.maxTokensPerBatch, - maxPricePointsPerBatch: tuning.maxPricePointsPerBatch, - maxTimestampsPerTokenPerBatch: tuning.maxTimestampsPerTokenPerBatch, - maxRequestUrlLength: tuning.maxRequestUrlLength, - useProApi: tuning.useProApi + 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, tuning))) + 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( - `[DefiLlama] Failed to fetch prices for ${batch.coinBatch.length} tokens and ${batchPricePoints} token-timestamp pairs:`, + `[${providerConfig.label}] Failed to fetch prices for ${batch.coinBatch.length} tokens and ${batchPricePoints} token-timestamp pairs:`, batchResult.reason ) - debugError('defillama', 'price batch group member failed', batchResult.reason, { - fetchGroup: fetchGroup.label, + 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.at(-1) ?? null + lastTimestamp: batchTimestamps.length > 0 ? batchTimestamps[batchTimestamps.length - 1] : null }) return } fetchStats.successfulBatches += 1 - const materializedPrices = materializeRequestedPrices(batch.coinBatch, batchResult.value) - const materializedPriceMisses = materializeRequestedPriceMisses(batch.coinBatch, batchResult.value) + const materializedPrices = materializeRequestedPrices(batch.coinBatch, batchResult.value, matchPriceAtTimestamp) materializedPrices.forEach(({ tokenKey, timestamp, price }) => { if (!result.has(tokenKey)) { result.set(tokenKey, new Map()) @@ -787,44 +917,34 @@ export async function fetchHistoricalPricesForTokenTimestamps( const existingMap = result.get(tokenKey)! existingMap.set(timestamp, price) - if (fetchGroup.cacheResults) { - newPrices.push({ tokenKey, timestamp, price }) - } }) - - if (fetchGroup.cacheResults) { - newPriceMisses.push(...materializedPriceMisses) - } }) - if (groupIndex < batchGroups.length - 1 && tuning.interGroupDelayMs > 0) { - await wait(tuning.interGroupDelayMs) + if (groupIndex < batchGroups.length - 1 && effectiveTuning.interGroupDelayMs > 0) { + await wait(effectiveTuning.interGroupDelayMs) } }, Promise.resolve()) } - await missingFetchGroups.reduce>(async (previousGroupPromise, fetchGroup) => { - await previousGroupPromise - await fetchMissingPriceGroup(fetchGroup) - }, Promise.resolve()) + await fetchPriceGroup(coins) if (fetchStats.successfulBatches === 0 && countPricePoints(result) === 0) { - throw new Error('Failed to fetch token prices from DefiLlama') + throw new Error(`Failed to fetch token prices from ${providerConfig.label}`) } - if (newPrices.length > 0) { - saveCachedPrices(newPrices).catch(() => {}) - } - - if (newPriceMisses.length > 0) { - saveCachedPriceMisses(newPriceMisses).catch(() => {}) + if (fetchStats.failedBatches > 0) { + Object.defineProperty(result, HISTORICAL_PRICE_FETCH_FAILED_BATCHES, { + value: fetchStats.failedBatches, + enumerable: false + }) } - debugLog('defillama', 'completed historical price fetch', { + debugLog('prices', 'completed historical price fetch', { + provider: providerConfig.provider, successfulBatches: fetchStats.successfulBatches, + failedBatches: fetchStats.failedBatches, totalPricePoints: countPricePoints(result), - newPrices: newPrices.length, - newPriceMisses: newPriceMisses.length + resolution }) return result diff --git a/api/lib/holdings/services/graphql.test.ts b/api/lib/holdings/services/graphql.test.ts index 3059d37eb..1bd95e513 100644 --- a/api/lib/holdings/services/graphql.test.ts +++ b/api/lib/holdings/services/graphql.test.ts @@ -13,164 +13,29 @@ function createGraphqlResponse(data: Record): Response { }) } +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('fetchRawUserPnlEvents', () => { +describe('fetchUserEvents', () => { afterEach(() => { vi.restoreAllMocks() vi.unstubAllGlobals() }) - it('enriches transaction events from address event tx hashes when tx-from queries are empty', async () => { - const txHashQueries: Array<{ queryName: string; chainId: number; transactionHashes: string[] }> = [] - 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 createGraphqlResponse({ - deposits: { aggregate: { count: 0 } }, - withdrawals: { aggregate: { count: 0 } }, - transfersIn: { aggregate: { count: 1 } }, - transfersOut: { aggregate: { count: 0 } }, - v2Deposits: { aggregate: { count: 0 } }, - v2Withdrawals: { aggregate: { count: 0 } } - }) - } - - if (query.includes('GetDepositsByTransactionHashes')) { - txHashQueries.push({ - queryName: 'Deposit', - chainId: Number(variables.chainId), - transactionHashes: [...((variables.transactionHashes as string[]) ?? [])] - }) - - return createGraphqlResponse({ - Deposit: [ - { - id: 'tx-hash-deposit', - vaultAddress: VAULT, - chainId: 1, - blockNumber: 2, - blockTimestamp: 200, - logIndex: 1, - transactionHash: TX_HASH, - transactionFrom: ROUTER, - owner: ROUTER, - sender: ROUTER, - assets: '1000', - shares: '900' - } - ] - }) - } - - if (query.includes('GetWithdrawalsByTransactionHashes')) { - txHashQueries.push({ - queryName: 'Withdraw', - chainId: Number(variables.chainId), - transactionHashes: [...((variables.transactionHashes as string[]) ?? [])] - }) - - return createGraphqlResponse({ Withdraw: [] }) - } - - if (query.includes('GetTransfersByTransactionHashes')) { - txHashQueries.push({ - queryName: 'Transfer', - chainId: Number(variables.chainId), - transactionHashes: [...((variables.transactionHashes as string[]) ?? [])] - }) - - return createGraphqlResponse({ Transfer: [] }) - } - - if (query.includes('GetV2DepositsByTransactionHashes')) { - return createGraphqlResponse({ V2Deposit: [] }) - } - - if (query.includes('GetV2WithdrawalsByTransactionHashes')) { - return createGraphqlResponse({ V2Withdraw: [] }) - } - - if (query.includes('GetTransfersIn')) { - return createGraphqlResponse({ - Transfer: [ - { - id: 'address-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(') || - query.includes('GetDepositsByTransactionFrom') || - query.includes('GetWithdrawalsByTransactionFrom') || - query.includes('GetV2DepositsByTransactionFrom') || - query.includes('GetV2WithdrawalsByTransactionFrom') || - query.includes('GetTransfersByTransactionFrom') - ) { - const resultKey = query.includes('V2Deposit') - ? 'V2Deposit' - : query.includes('V2Withdraw') - ? 'V2Withdraw' - : query.includes('Withdraw') - ? 'Withdraw' - : query.includes('Transfer') - ? 'Transfer' - : 'Deposit' - - return createGraphqlResponse({ [resultKey]: [] }) - } - - throw new Error(`Unexpected query: ${query}`) - }) - - vi.stubGlobal('fetch', fetchStub) - - const { fetchRawUserPnlEvents } = await importGraphqlModule() - const context = await fetchRawUserPnlEvents(USER, 'all', undefined, 'parallel') - - expect(context.addressEvents.transfersIn).toHaveLength(1) - expect(context.transactionEvents.deposits).toEqual([ - expect.objectContaining({ - id: 'tx-hash-deposit', - transactionHash: TX_HASH, - owner: ROUTER, - assets: '1000', - shares: '900' - }) - ]) - expect(context.transactionEvents.withdrawals).toEqual([]) - expect(txHashQueries).toEqual([ - { queryName: 'Deposit', chainId: 1, transactionHashes: [TX_HASH] }, - { queryName: 'Withdraw', chainId: 1, transactionHashes: [TX_HASH] }, - { queryName: 'Transfer', chainId: 1, transactionHashes: [TX_HASH] } - ]) - }) - it('falls back to sequential pagination when aggregate counts are unavailable', async () => { const transferBatches = [ Array.from({ length: 1000 }, (_, index) => ({ @@ -237,29 +102,9 @@ describe('fetchRawUserPnlEvents', () => { query.includes('GetWithdrawals(') || query.includes('GetTransfersOut') || query.includes('GetV2Deposits(') || - query.includes('GetV2Withdrawals(') || - query.includes('GetDepositsByTransactionFrom') || - query.includes('GetWithdrawalsByTransactionFrom') || - query.includes('GetV2DepositsByTransactionFrom') || - query.includes('GetV2WithdrawalsByTransactionFrom') || - query.includes('GetTransfersByTransactionFrom') || - query.includes('GetDepositsByTransactionHashes') || - query.includes('GetWithdrawalsByTransactionHashes') || - query.includes('GetTransfersByTransactionHashes') || - query.includes('GetV2DepositsByTransactionHashes') || - query.includes('GetV2WithdrawalsByTransactionHashes') + query.includes('GetV2Withdrawals(') ) { - const resultKey = query.includes('V2Deposit') - ? 'V2Deposit' - : query.includes('V2Withdraw') - ? 'V2Withdraw' - : query.includes('Withdraw') - ? 'Withdraw' - : query.includes('Transfer') - ? 'Transfer' - : 'Deposit' - - return createGraphqlResponse({ [resultKey]: [] }) + return createGraphqlResponse({ [getEmptyResultKey(query)]: [] }) } throw new Error(`Unexpected query: ${query}`) @@ -267,16 +112,16 @@ describe('fetchRawUserPnlEvents', () => { vi.stubGlobal('fetch', fetchStub) - const { fetchRawUserPnlEvents } = await importGraphqlModule() - const context = await fetchRawUserPnlEvents(USER, 'all', undefined, 'parallel') + const { fetchUserEvents } = await importGraphqlModule() + const events = await fetchUserEvents(USER, 'all', undefined, 'parallel') - expect(context.addressEvents.transfersIn).toHaveLength(1001) - expect(context.addressEvents.transfersIn[0]).toEqual( + expect(events.transfersIn).toHaveLength(1001) + expect(events.transfersIn[0]).toEqual( expect.objectContaining({ id: 'aggregate-transfer-in-0' }) ) - expect(context.addressEvents.transfersIn[1000]).toEqual( + expect(events.transfersIn[1000]).toEqual( expect.objectContaining({ id: 'aggregate-transfer-in-last' }) @@ -337,29 +182,9 @@ describe('fetchRawUserPnlEvents', () => { query.includes('GetWithdrawals(') || query.includes('GetTransfersOut') || query.includes('GetV2Deposits(') || - query.includes('GetV2Withdrawals(') || - query.includes('GetDepositsByTransactionFrom') || - query.includes('GetWithdrawalsByTransactionFrom') || - query.includes('GetV2DepositsByTransactionFrom') || - query.includes('GetV2WithdrawalsByTransactionFrom') || - query.includes('GetTransfersByTransactionFrom') || - query.includes('GetDepositsByTransactionHashes') || - query.includes('GetWithdrawalsByTransactionHashes') || - query.includes('GetTransfersByTransactionHashes') || - query.includes('GetV2DepositsByTransactionHashes') || - query.includes('GetV2WithdrawalsByTransactionHashes') + query.includes('GetV2Withdrawals(') ) { - const resultKey = query.includes('V2Deposit') - ? 'V2Deposit' - : query.includes('V2Withdraw') - ? 'V2Withdraw' - : query.includes('Withdraw') - ? 'Withdraw' - : query.includes('Transfer') - ? 'Transfer' - : 'Deposit' - - return createGraphqlResponse({ [resultKey]: [] }) + return createGraphqlResponse({ [getEmptyResultKey(query)]: [] }) } throw new Error(`Unexpected query: ${query}`) @@ -367,10 +192,10 @@ describe('fetchRawUserPnlEvents', () => { vi.stubGlobal('fetch', fetchStub) - const { fetchRawUserPnlEvents } = await importGraphqlModule() - const context = await fetchRawUserPnlEvents(USER, 'all', undefined, 'parallel', 'all') + const { fetchUserEvents } = await importGraphqlModule() + const events = await fetchUserEvents(USER, 'all', undefined, 'parallel', 'all') - expect(context.addressEvents.transfersIn).toEqual([ + expect(events.transfersIn).toEqual([ expect.objectContaining({ id: 'single-query-transfer-in' }) @@ -382,11 +207,10 @@ describe('fetchRawUserPnlEvents', () => { ).toBe(false) }) - it('reuses in-flight address-scoped fetches across history and pnl consumers', async () => { + 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 - variables: Record } const query = body.query @@ -415,29 +239,9 @@ describe('fetchRawUserPnlEvents', () => { query.includes('GetWithdrawals(') || query.includes('GetTransfersOut') || query.includes('GetV2Deposits(') || - query.includes('GetV2Withdrawals(') || - query.includes('GetDepositsByTransactionFrom') || - query.includes('GetWithdrawalsByTransactionFrom') || - query.includes('GetV2DepositsByTransactionFrom') || - query.includes('GetV2WithdrawalsByTransactionFrom') || - query.includes('GetTransfersByTransactionFrom') || - query.includes('GetDepositsByTransactionHashes') || - query.includes('GetWithdrawalsByTransactionHashes') || - query.includes('GetTransfersByTransactionHashes') || - query.includes('GetV2DepositsByTransactionHashes') || - query.includes('GetV2WithdrawalsByTransactionHashes') + query.includes('GetV2Withdrawals(') ) { - const resultKey = query.includes('V2Deposit') - ? 'V2Deposit' - : query.includes('V2Withdraw') - ? 'V2Withdraw' - : query.includes('Withdraw') - ? 'Withdraw' - : query.includes('Transfer') - ? 'Transfer' - : 'Deposit' - - return createGraphqlResponse({ [resultKey]: [] }) + return createGraphqlResponse({ [getEmptyResultKey(query)]: [] }) } throw new Error(`Unexpected query: ${query}`) @@ -445,18 +249,18 @@ describe('fetchRawUserPnlEvents', () => { vi.stubGlobal('fetch', fetchStub) - const { fetchRawUserPnlEvents, fetchUserEvents } = await importGraphqlModule() - const [historyEvents, pnlContext] = await Promise.all([ + const { fetchUserEvents } = await importGraphqlModule() + const [leftEvents, rightEvents] = await Promise.all([ fetchUserEvents(USER, 'all', 123456), - fetchRawUserPnlEvents(USER, 'all', 123456) + fetchUserEvents(USER, 'all', 123456) ]) - expect(historyEvents.transfersIn).toEqual([ + expect(leftEvents.transfersIn).toEqual([ expect.objectContaining({ id: 'shared-transfer-in' }) ]) - expect(pnlContext.addressEvents.transfersIn).toEqual([ + expect(rightEvents.transfersIn).toEqual([ expect.objectContaining({ id: 'shared-transfer-in' }) @@ -471,10 +275,5 @@ describe('fetchRawUserPnlEvents', () => { String((init as RequestInit | undefined)?.body ?? '').includes('GetDeposits(') ) ).toHaveLength(1) - expect( - fetchStub.mock.calls.filter(([, init]) => - String((init as RequestInit | undefined)?.body ?? '').includes('GetTransfersByTransactionFrom') - ) - ).toHaveLength(1) }) }) diff --git a/api/lib/holdings/services/graphql.ts b/api/lib/holdings/services/graphql.ts index d9ed90b17..c63038bb8 100644 --- a/api/lib/holdings/services/graphql.ts +++ b/api/lib/holdings/services/graphql.ts @@ -1,5 +1,5 @@ import { getAddress } from 'viem' -import { config } from '../config' +import { holdingsConfig } from '../config' import type { DepositEvent, TransferEvent, UserEvents, V2DepositEvent, V2WithdrawEvent, WithdrawEvent } from '../types' import { debugError, debugLog } from './debug' @@ -226,97 +226,6 @@ const RECENT_TRANSFERS_OUT_QUERY = ` } ` -const DEPOSITS_BY_TX_FROM_QUERY = ` - query GetDepositsByTransactionFrom($transactionFrom: String!, $limit: Int!, $offset: Int!, $maxTimestamp: Int) { - Deposit(where: { transactionFrom: { _eq: $transactionFrom }, 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_FROM_QUERY = ` - query GetWithdrawalsByTransactionFrom($transactionFrom: String!, $limit: Int!, $offset: Int!, $maxTimestamp: Int) { - Withdraw(where: { transactionFrom: { _eq: $transactionFrom }, 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_FROM_QUERY = ` - query GetV2DepositsByTransactionFrom($transactionFrom: String!, $limit: Int!, $offset: Int!, $maxTimestamp: Int) { - V2Deposit(where: { transactionFrom: { _eq: $transactionFrom }, 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_FROM_QUERY = ` - query GetV2WithdrawalsByTransactionFrom($transactionFrom: String!, $limit: Int!, $offset: Int!, $maxTimestamp: Int) { - V2Withdraw(where: { transactionFrom: { _eq: $transactionFrom }, 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_FROM_QUERY = ` - query GetTransfersByTransactionFrom($transactionFrom: String!, $limit: Int!, $offset: Int!, $maxTimestamp: Int) { - Transfer(where: { transactionFrom: { _eq: $transactionFrom }, 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 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) { @@ -473,12 +382,12 @@ async function executeQuery(query: string, variables: Record } // Only add admin secret if explicitly configured (not the default 'testing' value) - const password = config.envioPassword + const password = holdingsConfig.envioPassword if (password && password !== 'testing') { headers['x-hasura-admin-secret'] = password } - const response = await fetch(config.envioGraphqlUrl, { + const response = await fetch(holdingsConfig.envioGraphqlUrl, { method: 'POST', headers, body: JSON.stringify({ query, variables }) @@ -639,26 +548,6 @@ function dedupeById(events: T[]): T[] { ) } -function collectAddressTransactionHashes(events: UserEvents): Map { - const groupedHashes = new Map>() - const allAddressEvents = [...events.deposits, ...events.withdrawals, ...events.transfersIn, ...events.transfersOut] - - allAddressEvents.forEach((event) => { - if (!event.transactionHash) { - return - } - - const txHashes = groupedHashes.get(event.chainId) ?? new Set() - txHashes.add(event.transactionHash.toLowerCase()) - groupedHashes.set(event.chainId, txHashes) - }) - - return Array.from(groupedHashes.entries()).reduce>((grouped, [chainId, hashes]) => { - grouped.set(chainId, Array.from(hashes.values())) - return grouped - }, new Map()) -} - async function fetchTransactionHashBatch( query: string, chainId: number, @@ -881,25 +770,8 @@ type AddressEventResults = [ TransferEvent[] ] -type TransactionFromEventResults = [ - DepositEvent[], - WithdrawEvent[], - V2DepositEvent[], - V2WithdrawEvent[], - TransferEvent[] -] - const inFlightAddressScopedEventFetches = new Map>() -export interface RawPnlEventContext { - addressEvents: UserEvents - transactionEvents: { - deposits: DepositEvent[] - withdrawals: WithdrawEvent[] - transfers: TransferEvent[] - } -} - function sortByBlock(events: T[]): T[] { return [...events].sort( (a, b) => a.blockTimestamp - b.blockTimestamp || a.blockNumber - b.blockNumber || a.logIndex - b.logIndex @@ -1158,28 +1030,6 @@ async function fetchAddressScopedEvents( return request } -function getTransactionFromEventFetches( - addressLower: string, - maxTimestamp: number | undefined, - paginationMode: HoldingsEventPaginationMode -): Promise { - const fetchFn = paginationMode === 'all' ? fetchAllSingleQuery : fetchAllSequential - - return Promise.all([ - fetchFn(DEPOSITS_BY_TX_FROM_QUERY, 'transactionFrom', addressLower, 'Deposit', maxTimestamp), - fetchFn(WITHDRAWALS_BY_TX_FROM_QUERY, 'transactionFrom', addressLower, 'Withdraw', maxTimestamp), - fetchFn(V2_DEPOSITS_BY_TX_FROM_QUERY, 'transactionFrom', addressLower, 'V2Deposit', maxTimestamp), - fetchFn( - V2_WITHDRAWALS_BY_TX_FROM_QUERY, - 'transactionFrom', - addressLower, - 'V2Withdraw', - maxTimestamp - ), - fetchFn(TRANSFERS_BY_TX_FROM_QUERY, 'transactionFrom', addressLower, 'Transfer', maxTimestamp) - ]) as Promise -} - // Main export - defaults to sequential paged fetching but allows overrides for experimentation/debugging. export async function fetchUserEvents( userAddress: string, @@ -1238,21 +1088,31 @@ export async function fetchRecentAddressScopedActivityEvents( userAddress: string, version: VaultVersion = 'all', limitPerSource = 25, - maxTimestamp?: number + 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, 0, maxTimestamp), + fetchRecentLimited( + RECENT_DEPOSITS_QUERY, + 'owner', + address, + 'Deposit', + boundedLimit, + boundedOffset, + maxTimestamp + ), fetchRecentLimited( RECENT_WITHDRAWALS_QUERY, 'owner', address, 'Withdraw', boundedLimit, - 0, + boundedOffset, maxTimestamp ), fetchRecentLimited( @@ -1261,7 +1121,7 @@ export async function fetchRecentAddressScopedActivityEvents( address, 'V2Deposit', boundedLimit, - 0, + boundedOffset, maxTimestamp ), fetchRecentLimited( @@ -1270,7 +1130,7 @@ export async function fetchRecentAddressScopedActivityEvents( address, 'V2Withdraw', boundedLimit, - 0, + boundedOffset, maxTimestamp ), fetchRecentLimited( @@ -1279,7 +1139,7 @@ export async function fetchRecentAddressScopedActivityEvents( address, 'Transfer', boundedLimit, - 0, + boundedOffset, maxTimestamp ), fetchRecentLimited( @@ -1288,7 +1148,7 @@ export async function fetchRecentAddressScopedActivityEvents( address, 'Transfer', boundedLimit, - 0, + boundedOffset, maxTimestamp ) ]) @@ -1324,6 +1184,7 @@ export async function fetchRecentAddressScopedActivityEvents( hasMoreWithdrawals, hasMoreTransfersIn, hasMoreTransfersOut, + offsetPerSource: boundedOffset, maxTimestamp: maxTimestamp ?? null }) @@ -1387,104 +1248,6 @@ export async function fetchActivityEventsByTransactionHashes( } } -export async function fetchRawUserPnlEvents( - userAddress: string, - version: VaultVersion = 'all', - maxTimestamp?: number, - fetchType: HoldingsEventFetchType = 'seq', - paginationMode: HoldingsEventPaginationMode = 'paged' -): Promise { - const address = getGraphqlAddress(userAddress) - const addressLower = address.toLowerCase() - const addressEventsPromise = fetchAddressScopedEvents(address, maxTimestamp, fetchType, paginationMode) - const transactionFromEventsPromise = getTransactionFromEventFetches(address, maxTimestamp, paginationMode) - - const [ - [ - addressV3Deposits, - addressV3Withdrawals, - addressV2DepositsRaw, - addressV2WithdrawalsRaw, - addressTransfersIn, - addressTransfersOut - ], - [txV3Deposits, txV3Withdrawals, txV2DepositsRaw, txV2WithdrawalsRaw, txTransfers] - ] = await Promise.all([addressEventsPromise, transactionFromEventsPromise]) - - const addressEvents = { - deposits: getDepositsByVersion(addressV3Deposits, addressV2DepositsRaw, version), - withdrawals: getWithdrawalsByVersion(addressV3Withdrawals, addressV2WithdrawalsRaw, version), - transfersIn: sortByBlock(addressTransfersIn), - transfersOut: sortByBlock(addressTransfersOut) - } - const addressTransactionHashes = collectAddressTransactionHashes(addressEvents) - const [txHashV3Deposits, txHashV3Withdrawals, txHashV2DepositsRaw, txHashV2WithdrawalsRaw, txHashTransfers] = - await Promise.all([ - fetchAllByTransactionHashes( - DEPOSITS_BY_TX_HASHES_QUERY, - addressTransactionHashes, - 'Deposit', - maxTimestamp - ), - fetchAllByTransactionHashes( - WITHDRAWALS_BY_TX_HASHES_QUERY, - addressTransactionHashes, - 'Withdraw', - maxTimestamp - ), - fetchAllByTransactionHashes( - V2_DEPOSITS_BY_TX_HASHES_QUERY, - addressTransactionHashes, - 'V2Deposit', - maxTimestamp - ), - fetchAllByTransactionHashes( - V2_WITHDRAWALS_BY_TX_HASHES_QUERY, - addressTransactionHashes, - 'V2Withdraw', - maxTimestamp - ), - fetchAllByTransactionHashes( - TRANSFERS_BY_TX_HASHES_QUERY, - addressTransactionHashes, - 'Transfer', - maxTimestamp - ) - ]) - - const context = { - addressEvents, - transactionEvents: { - deposits: sortByBlock( - dedupeById([ - ...getDepositsByVersion(txV3Deposits, txV2DepositsRaw, version), - ...getDepositsByVersion(txHashV3Deposits, txHashV2DepositsRaw, version) - ]) - ), - withdrawals: sortByBlock( - dedupeById([ - ...getWithdrawalsByVersion(txV3Withdrawals, txV2WithdrawalsRaw, version), - ...getWithdrawalsByVersion(txHashV3Withdrawals, txHashV2WithdrawalsRaw, version) - ]) - ), - transfers: sortByBlock(dedupeById([...txTransfers, ...txHashTransfers])) - } - } - debugLog('graphql', 'fetched raw user pnl events', { - address: addressLower, - version, - addressDeposits: context.addressEvents.deposits.length, - addressWithdrawals: context.addressEvents.withdrawals.length, - addressTransfersIn: context.addressEvents.transfersIn.length, - addressTransfersOut: context.addressEvents.transfersOut.length, - txDeposits: context.transactionEvents.deposits.length, - txWithdrawals: context.transactionEvents.withdrawals.length, - txTransfers: context.transactionEvents.transfers.length, - maxTimestamp: maxTimestamp ?? null - }) - return context -} - // Shared processing logic for both fetch strategies function processEvents( v3Deposits: DepositEvent[], diff --git a/api/lib/holdings/services/kong.ts b/api/lib/holdings/services/kong.ts index 36a62a7f4..cd5780fb5 100644 --- a/api/lib/holdings/services/kong.ts +++ b/api/lib/holdings/services/kong.ts @@ -1,4 +1,4 @@ -import { config } from '../config' +import { holdingsConfig } from '../config' import type { KongPPSDataPoint } from '../types' import { debugError, debugLog } from './debug' @@ -119,7 +119,7 @@ export async function fetchVaultPPS( vaultAddress: string, options?: TKongFetchOptions ): Promise { - const url = `${config.kongBaseUrl}/api/rest/timeseries/pps/${chainId}/${vaultAddress}` + const url = `${holdingsConfig.kongBaseUrl}/api/rest/timeseries/pps/${chainId}/${vaultAddress}` const response = await getFetchFn(options)(url, { signal: AbortSignal.timeout(getTimeoutMs(options)) }) if (!response.ok) { diff --git a/api/lib/holdings/services/nestedVaultPrices.test.ts b/api/lib/holdings/services/nestedVaultPrices.test.ts index 1ef6d69dd..52f0bb33d 100644 --- a/api/lib/holdings/services/nestedVaultPrices.test.ts +++ b/api/lib/holdings/services/nestedVaultPrices.test.ts @@ -10,6 +10,7 @@ import { toVaultKey } from './pnlShared' const INNER_VAULT = '0x696d02db93291651ed510704c9b286841d506987' const OUTER_VAULT = '0xaaafea48472f77563961cdb53291dedfb46f9040' +const SUPER_VAULT = '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' const UNDERLYING = '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48' const metadata = new Map([ @@ -42,6 +43,21 @@ const metadata = new Map([ }, 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 + } ] ]) @@ -52,8 +68,7 @@ describe('nested vault asset prices', () => { { chainId: 1, address: INNER_VAULT, - timestamps: [100, 200], - uncachedTimestamps: [200] + timestamps: [100, 200] } ], metadata @@ -63,14 +78,12 @@ describe('nested vault asset prices', () => { { chainId: 1, address: INNER_VAULT, - timestamps: [100, 200], - uncachedTimestamps: [200] + timestamps: [100, 200] }, { chainId: 1, address: UNDERLYING, - timestamps: [100, 200], - uncachedTimestamps: [200] + timestamps: [100, 200] } ]) }) @@ -90,6 +103,59 @@ describe('nested vault asset prices', () => { ).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([ @@ -126,6 +192,70 @@ describe('nested vault asset prices', () => { 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([ diff --git a/api/lib/holdings/services/nestedVaultPrices.ts b/api/lib/holdings/services/nestedVaultPrices.ts index 6336ddbe5..e6ae6da96 100644 --- a/api/lib/holdings/services/nestedVaultPrices.ts +++ b/api/lib/holdings/services/nestedVaultPrices.ts @@ -1,5 +1,5 @@ -import type { VaultMetadata } from '../types' -import { getChainPrefix, getPriceAtTimestamp, type THistoricalPriceRequest } from './defillama' +import { SUPPORTED_CHAINS, type VaultMetadata } from '../types' +import type { THistoricalPriceRequest } from './defillama' import { getPPS, type PPSTimeline } from './kong' import { toVaultKey } from './pnlShared' @@ -7,8 +7,6 @@ type TPriceRequestDraft = { chainId: number address: string timestamps: Set - uncachedTimestamps: Set - includeUncachedTimestamps: boolean } type TVaultIdentifier = { @@ -16,50 +14,53 @@ type TVaultIdentifier = { 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, - includeUncachedTimestamps: boolean -): void { +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(), - uncachedTimestamps: new Set(), - includeUncachedTimestamps + timestamps: new Set() } request.timestamps.forEach((timestamp) => { draft.timestamps.add(timestamp) }) - request.uncachedTimestamps?.forEach((timestamp) => { - draft.timestamps.add(timestamp) - draft.uncachedTimestamps.add(timestamp) - }) - draft.includeUncachedTimestamps ||= includeUncachedTimestamps drafts.set(key, draft) } function materializePriceRequests(drafts: Map): THistoricalPriceRequest[] { - return Array.from(drafts.values()).map((draft) => { - const uncachedTimestamps = Array.from(draft.uncachedTimestamps).sort((a, b) => a - b) - - return { - chainId: draft.chainId, - address: draft.address, - timestamps: Array.from(draft.timestamps).sort((a, b) => a - b), - ...(draft.includeUncachedTimestamps || uncachedTimestamps.length > 0 ? { uncachedTimestamps } : {}) - } - }) + 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[] { @@ -85,50 +86,115 @@ export function getAssetVaultMetadataLookupIdentifiers(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 + vaultMetadata: Map, + maxDepth = DEFAULT_MAX_NESTED_VAULT_DEPTH ): TVaultIdentifier[] { return mergeVaultIdentifiers( - requests.flatMap((request) => { - const nestedVault = vaultMetadata.get(toVaultKey(request.chainId, request.address)) - - return nestedVault ? [{ chainId: nestedVault.chainId, vaultAddress: nestedVault.address }] : [] - }) + 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 + vaultMetadata: Map, + maxDepth = DEFAULT_MAX_NESTED_VAULT_DEPTH ): THistoricalPriceRequest[] { const drafts = new Map() requests.forEach((request) => { - const includeUncachedTimestamps = request.uncachedTimestamps !== undefined - addPriceRequest(drafts, request, includeUncachedTimestamps) - - const nestedVault = vaultMetadata.get(toVaultKey(request.chainId, request.address)) - if (!nestedVault) { - return - } - - addPriceRequest( - drafts, - { - chainId: nestedVault.chainId, - address: nestedVault.token.address, - timestamps: request.timestamps, - uncachedTimestamps: request.uncachedTimestamps - }, - includeUncachedTimestamps - ) + addPriceRequest(drafts, request) + addNestedVaultAssetPriceRequests(drafts, request, vaultMetadata, maxDepth) }) return materializePriceRequests(drafts) } -export function deriveNestedVaultAssetPriceData(args: { +function deriveNestedVaultAssetPriceDataOnce(args: { priceData: Map> priceRequests: THistoricalPriceRequest[] vaultMetadata: Map @@ -170,3 +236,24 @@ export function deriveNestedVaultAssetPriceData(args: { 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/pnl.modes.test.ts b/api/lib/holdings/services/pnl.modes.test.ts deleted file mode 100644 index c97ed5531..000000000 --- a/api/lib/holdings/services/pnl.modes.test.ts +++ /dev/null @@ -1,792 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import type { RawPnlEventContext } from './graphql' -import type { HoldingsPnLDrilldownResponse, HoldingsPnLResponse } from './pnl' - -const VAULT = '0xbe53a109b494e5c9f97b9cd39fe969be68bf6204' -const REWARD_DISTRIBUTOR = '0xb226c52eb411326cdb54824a88abafdaaff16d3d' -const REWARD_VAULT = '0xbf319ddc2edc1eb6fdf9910e39b37be221c8805f' -const USER = '0x93a62da5a14c80f265dabc077fcee437b1a0efde' -const ROUTER = '0x1111111111111111111111111111111111111111' -const PRICE_KEY = 'ethereum:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48' - -const fetchRawUserPnlEventsMock = vi.fn<() => Promise>() -const fetchMultipleVaultsMetadataMock = vi.fn() -const fetchMultipleVaultsPPSMock = vi.fn() -const fetchHistoricalPricesMock = vi.fn() - -vi.mock('./graphql', async () => { - const actual = await vi.importActual('./graphql') - return { - ...actual, - fetchRawUserPnlEvents: fetchRawUserPnlEventsMock - } -}) - -vi.mock('./vaults', async () => { - const actual = await vi.importActual('./vaults') - return { - ...actual, - fetchMultipleVaultsMetadata: fetchMultipleVaultsMetadataMock - } -}) - -vi.mock('./kong', async () => { - const actual = await vi.importActual('./kong') - return { - ...actual, - fetchMultipleVaultsPPS: fetchMultipleVaultsPPSMock - } -}) - -vi.mock('./defillama', async () => { - const actual = await vi.importActual('./defillama') - return { - ...actual, - fetchHistoricalPrices: fetchHistoricalPricesMock - } -}) - -function createTransferInContext(): RawPnlEventContext { - return { - addressEvents: { - deposits: [ - { - id: 'anchor-deposit', - vaultAddress: VAULT, - chainId: 1, - blockNumber: 3, - blockTimestamp: 300, - logIndex: 1, - transactionHash: '0xanchor-deposit', - transactionFrom: USER, - owner: USER, - sender: USER, - assets: '1100000', - shares: '1000000' - } - ], - withdrawals: [], - transfersIn: [ - { - id: 'unknown-transfer-in', - vaultAddress: VAULT, - chainId: 1, - blockNumber: 1, - blockTimestamp: 100, - logIndex: 1, - transactionHash: '0xtransfer-in', - transactionFrom: ROUTER, - sender: ROUTER, - receiver: USER, - value: '100000000' - } - ], - transfersOut: [] - }, - transactionEvents: { - deposits: [], - withdrawals: [], - transfers: [] - } - } -} - -function createUnknownWithdrawalContext(): RawPnlEventContext { - return { - addressEvents: { - deposits: [], - withdrawals: [ - { - id: 'unknown-withdrawal', - vaultAddress: VAULT, - chainId: 1, - blockNumber: 2, - blockTimestamp: 200, - logIndex: 1, - transactionHash: '0xwithdraw', - transactionFrom: USER, - owner: USER, - assets: '120000000', - shares: '100000000' - } - ], - transfersIn: createTransferInContext().addressEvents.transfersIn, - transfersOut: [] - }, - transactionEvents: { - deposits: [], - withdrawals: [], - transfers: [] - } - } -} - -function createTransferOnlyContext(): RawPnlEventContext { - return { - addressEvents: { - deposits: [], - withdrawals: [], - transfersIn: createTransferInContext().addressEvents.transfersIn, - transfersOut: [] - }, - transactionEvents: { - deposits: [], - withdrawals: [], - transfers: [] - } - } -} - -function createRewardTransferInContext(): RawPnlEventContext { - const rewardTransferIn = { - id: 'reward-transfer-in', - vaultAddress: REWARD_VAULT, - chainId: 1, - blockNumber: 1, - blockTimestamp: 100, - logIndex: 1, - transactionHash: '0xreward-transfer-in', - transactionFrom: REWARD_DISTRIBUTOR, - sender: REWARD_DISTRIBUTOR, - receiver: USER, - value: '100000000' - } - - return { - addressEvents: { - deposits: [], - withdrawals: [], - transfersIn: [rewardTransferIn], - transfersOut: [] - }, - transactionEvents: { - deposits: [], - withdrawals: [], - transfers: [rewardTransferIn] - } - } -} - -function createTransferOutContext(): RawPnlEventContext { - return { - addressEvents: { - deposits: createTransferInContext().addressEvents.deposits, - withdrawals: [], - transfersIn: createTransferInContext().addressEvents.transfersIn, - transfersOut: [ - { - id: 'unknown-transfer-out', - vaultAddress: VAULT, - chainId: 1, - blockNumber: 2, - blockTimestamp: 200, - logIndex: 1, - transactionHash: '0xtransfer-out', - transactionFrom: USER, - sender: USER, - receiver: ROUTER, - value: '100000000' - } - ] - }, - transactionEvents: { - deposits: [], - withdrawals: [], - transfers: [] - } - } -} - -function configureServiceMocks( - rawContext: RawPnlEventContext, - overrides?: { - vaultAddress?: string - metadata?: { - tokenAddress: string - symbol: string - decimals: number - shareDecimals?: number - category?: 'stable' | 'volatile' - isHidden?: boolean - } - ppsTimeline?: Map - priceKey?: string - prices?: Map - } -): void { - const vaultAddress = overrides?.vaultAddress ?? VAULT - const metadata = overrides?.metadata ?? { - tokenAddress: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', - symbol: 'USDC', - decimals: 6, - shareDecimals: 6, - category: 'stable' as const - } - const ppsTimeline = - overrides?.ppsTimeline ?? - new Map([ - [100, 1], - [200, 1.2], - [300, 1.1] - ]) - const priceKey = overrides?.priceKey ?? PRICE_KEY - const prices = - overrides?.prices ?? - new Map([ - [100, 2], - [200, 3], - [300, 3] - ]) - - fetchRawUserPnlEventsMock.mockResolvedValue(rawContext) - fetchMultipleVaultsMetadataMock.mockResolvedValue( - new Map([ - [ - `1:${vaultAddress}`, - { - address: vaultAddress, - chainId: 1, - version: 'v3', - isHidden: metadata.isHidden ?? false, - token: { - address: metadata.tokenAddress, - symbol: metadata.symbol, - decimals: metadata.decimals - }, - category: metadata.category ?? 'volatile', - decimals: metadata.shareDecimals ?? metadata.decimals - } - ] - ]) - ) - fetchMultipleVaultsPPSMock.mockResolvedValue(new Map([[`1:${vaultAddress}`, ppsTimeline]])) - fetchHistoricalPricesMock.mockResolvedValue(new Map([[priceKey, prices]])) -} - -async function importPnlModule() { - vi.resetModules() - return import('./pnl') -} - -async function getSingleVaultResponse( - rawContext: RawPnlEventContext, - unknownMode?: 'strict' | 'zero_basis' | 'windfall', - overrides?: Parameters[1] -): Promise { - configureServiceMocks(rawContext, overrides) - const { getHoldingsPnL } = await importPnlModule() - const response = await getHoldingsPnL(USER, 'all', unknownMode) - expect(response.vaults).toHaveLength(1) - return response -} - -async function getSingleVaultDrilldownResponse( - rawContext: RawPnlEventContext, - unknownMode?: 'strict' | 'zero_basis' | 'windfall', - overrides?: Parameters[1] -): Promise { - configureServiceMocks(rawContext, overrides) - const { getHoldingsPnLDrilldown } = await importPnlModule() - const response = await getHoldingsPnLDrilldown(USER, 'all', unknownMode) - expect(response.vaults).toHaveLength(1) - return response -} - -describe('getHoldingsPnL unknown transfer-in modes', () => { - afterEach(() => { - vi.restoreAllMocks() - vi.clearAllMocks() - vi.unstubAllGlobals() - }) - - it('defaults to windfall and splits free transfer-ins into windfall plus market unrealized pnl', async () => { - vi.spyOn(Date, 'now').mockReturnValue(300_000) - - const response = await getSingleVaultResponse(createTransferInContext()) - const vault = response.vaults[0] - - expect(response.unknownTransferInPnlMode).toBe('windfall') - expect(response.summary.totalUnknownCostBasisValueUsd).toBe(0) - expect(response.summary.totalWindfallPnlUsd).toBeCloseTo(200) - expect(response.summary.totalUnrealizedPnlUsd).toBeCloseTo(130) - expect(response.summary.totalPnlUsd).toBeCloseTo(130) - expect(response.summary.totalEconomicGainUsd).toBeCloseTo(330) - expect(vault.windfallPnlUsd).toBeCloseTo(200) - expect(vault.unrealizedPnlUsd).toBeCloseTo(130) - expect(vault.totalPnlUsd).toBeCloseTo(130) - expect(vault.totalEconomicGainUsd).toBeCloseTo(330) - }) - - it('uses paged sequential event fetching by default and allows explicit overrides', async () => { - vi.spyOn(Date, 'now').mockReturnValue(300_000) - configureServiceMocks(createTransferInContext()) - - const { getHoldingsPnL } = await importPnlModule() - - await getHoldingsPnL(USER) - expect(fetchRawUserPnlEventsMock).toHaveBeenLastCalledWith(USER, 'all', undefined, 'seq', 'paged') - - await getHoldingsPnL(USER, 'all', 'windfall', 'parallel', 'all') - expect(fetchRawUserPnlEventsMock).toHaveBeenLastCalledWith(USER, 'all', undefined, 'parallel', 'all') - }) - - it('filters versioned pnl responses using authoritative vault metadata', async () => { - vi.spyOn(Date, 'now').mockReturnValue(300_000) - configureServiceMocks(createTransferInContext()) - - const { getHoldingsPnL } = await importPnlModule() - - const v2Response = await getHoldingsPnL(USER, 'v2') - expect(v2Response.summary.totalVaults).toBe(0) - expect(v2Response.vaults).toEqual([]) - expect(fetchRawUserPnlEventsMock).toHaveBeenLastCalledWith(USER, 'all', undefined, 'seq', 'paged') - - const v3Response = await getHoldingsPnL(USER, 'v3') - expect(v3Response.summary.totalVaults).toBe(1) - expect(v3Response.vaults[0]?.vaultAddress).toBe(VAULT) - expect(fetchRawUserPnlEventsMock).toHaveBeenLastCalledWith(USER, 'all', undefined, 'seq', 'paged') - }) - - it('excludes hidden vaults from pnl responses', async () => { - vi.spyOn(Date, 'now').mockReturnValue(300_000) - configureServiceMocks(createTransferInContext(), { - metadata: { - tokenAddress: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', - symbol: 'USDC', - decimals: 6, - shareDecimals: 6, - category: 'stable', - isHidden: true - } - }) - - const { getHoldingsPnL } = await importPnlModule() - const response = await getHoldingsPnL(USER) - - expect(response.summary.totalVaults).toBe(0) - expect(response.vaults).toEqual([]) - expect(fetchMultipleVaultsPPSMock).not.toHaveBeenCalled() - expect(fetchHistoricalPricesMock).not.toHaveBeenCalled() - }) - - it('treats unknown transfer-ins as full unrealized pnl in zero-basis mode', async () => { - vi.spyOn(Date, 'now').mockReturnValue(300_000) - - const response = await getSingleVaultResponse(createTransferInContext(), 'zero_basis') - const vault = response.vaults[0] - - expect(response.summary.totalUnknownCostBasisValueUsd).toBe(0) - expect(response.summary.totalWindfallPnlUsd).toBe(0) - expect(response.summary.totalUnrealizedPnlUsd).toBeCloseTo(330) - expect(response.summary.totalPnlUsd).toBeCloseTo(330) - expect(response.summary.totalEconomicGainUsd).toBeCloseTo(330) - expect(vault.windfallPnlUsd).toBe(0) - expect(vault.unrealizedPnlUsd).toBeCloseTo(330) - expect(vault.totalPnlUsd).toBeCloseTo(330) - expect(vault.totalEconomicGainUsd).toBeCloseTo(330) - }) - - it('treats recognized reward transfer-ins as complete zero-basis lots in windfall mode', async () => { - vi.spyOn(Date, 'now').mockReturnValue(300_000) - - const response = await getSingleVaultDrilldownResponse(createRewardTransferInContext(), 'windfall', { - vaultAddress: REWARD_VAULT - }) - const vault = response.vaults[0] - - expect(vault.costBasisStatus).toBe('complete') - expect(vault.windfallPnlUsd).toBe(0) - expect(vault.unrealizedPnlUsd).toBeCloseTo(330) - expect(vault.totalPnlUsd).toBeCloseTo(330) - expect(vault.totalEconomicGainUsd).toBeCloseTo(330) - expect(vault.eventCounts.rewardTransfersIn).toBe(1) - expect(vault.eventCounts.externalTransfersIn).toBe(0) - expect(vault.rewardTransferInEntries).toHaveLength(1) - expect(vault.rewardTransferInEntries[0]).toMatchObject({ - distributor: REWARD_DISTRIBUTOR, - location: 'vault', - sharesFormatted: 100, - receiptValueUsd: 200 - }) - expect(vault.unknownTransferInEntries).toEqual([]) - expect(vault.currentLots.vault).toHaveLength(1) - expect(vault.currentLots.vault[0]?.costBasis).toBe('0') - expect(vault.currentLots.vault[0]?.costBasisUsd).toBe(0) - expect(vault.currentLots.vault[0]?.currentValueUsd).toBeCloseTo(330) - expect(vault.journal).toHaveLength(1) - expect(vault.journal[0]).toMatchObject({ - txHash: '0xreward-transfer-in', - view: 'reward_in_vault', - rewardInVaultShares: '100000000', - unknownInVaultShares: '0' - }) - }) - - it('preserves strict-mode unknown cost basis behavior', async () => { - vi.spyOn(Date, 'now').mockReturnValue(300_000) - - const response = await getSingleVaultResponse(createTransferInContext(), 'strict') - const vault = response.vaults[0] - - expect(response.summary.totalUnknownCostBasisValueUsd).toBeCloseTo(330) - expect(response.summary.totalWindfallPnlUsd).toBe(0) - expect(response.summary.totalUnrealizedPnlUsd).toBe(0) - expect(response.summary.totalPnlUsd).toBe(0) - expect(response.summary.totalEconomicGainUsd).toBe(0) - expect(vault.unknownCostBasisValueUsd).toBeCloseTo(330) - expect(vault.totalPnlUsd).toBe(0) - expect(vault.totalEconomicGainUsd).toBe(0) - }) - - it('values transfer-only current holdings without fetching historical receipt prices', async () => { - vi.spyOn(Date, 'now').mockReturnValue(300_000) - - const windfallResponse = await getSingleVaultResponse(createTransferOnlyContext(), 'windfall') - const zeroBasisResponse = await getSingleVaultResponse(createTransferOnlyContext(), 'zero_basis') - const strictResponse = await getSingleVaultResponse(createTransferOnlyContext(), 'strict') - - expect(fetchHistoricalPricesMock).toHaveBeenNthCalledWith( - 1, - [{ chainId: 1, address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48' }], - [300] - ) - expect(fetchHistoricalPricesMock).toHaveBeenNthCalledWith( - 2, - [{ chainId: 1, address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48' }], - [300] - ) - expect(fetchHistoricalPricesMock).toHaveBeenNthCalledWith( - 3, - [{ chainId: 1, address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48' }], - [300] - ) - - expect(windfallResponse.summary.totalCurrentValueUsd).toBeCloseTo(330) - expect(windfallResponse.summary.totalWindfallPnlUsd).toBeCloseTo(330) - expect(windfallResponse.summary.totalPnlUsd).toBe(0) - expect(windfallResponse.summary.totalEconomicGainUsd).toBeCloseTo(330) - - expect(zeroBasisResponse.summary.totalCurrentValueUsd).toBeCloseTo(330) - expect(zeroBasisResponse.summary.totalUnrealizedPnlUsd).toBeCloseTo(330) - expect(zeroBasisResponse.summary.totalPnlUsd).toBeCloseTo(330) - expect(zeroBasisResponse.summary.totalEconomicGainUsd).toBeCloseTo(330) - - expect(strictResponse.summary.totalCurrentValueUsd).toBeCloseTo(330) - expect(strictResponse.summary.totalUnknownCostBasisValueUsd).toBeCloseTo(330) - expect(strictResponse.summary.totalPnlUsd).toBe(0) - expect(strictResponse.summary.totalEconomicGainUsd).toBe(0) - }) - - it('computes realized pnl for unknown withdrawals in zero-basis and windfall modes', async () => { - vi.spyOn(Date, 'now').mockReturnValue(300_000) - - const zeroBasisResponse = await getSingleVaultResponse(createUnknownWithdrawalContext(), 'zero_basis') - const windfallResponse = await getSingleVaultResponse(createUnknownWithdrawalContext(), 'windfall') - - expect(zeroBasisResponse.summary.totalRealizedPnlUsd).toBeCloseTo(360) - expect(zeroBasisResponse.summary.totalWindfallPnlUsd).toBe(0) - expect(zeroBasisResponse.summary.totalPnlUsd).toBeCloseTo(360) - expect(zeroBasisResponse.summary.totalEconomicGainUsd).toBeCloseTo(360) - - expect(windfallResponse.summary.totalWindfallPnlUsd).toBeCloseTo(200) - expect(windfallResponse.summary.totalRealizedPnlUsd).toBeCloseTo(160) - expect(windfallResponse.summary.totalPnlUsd).toBeCloseTo(160) - expect(windfallResponse.summary.totalEconomicGainUsd).toBeCloseTo(360) - }) - - it('does not keep windfall on unknown shares that later leave through external transfers', async () => { - vi.spyOn(Date, 'now').mockReturnValue(300_000) - - const zeroBasisResponse = await getSingleVaultResponse(createTransferOutContext(), 'zero_basis') - const windfallResponse = await getSingleVaultResponse(createTransferOutContext(), 'windfall') - - expect(zeroBasisResponse.summary.totalPnlUsd).toBe(0) - expect(zeroBasisResponse.summary.totalEconomicGainUsd).toBe(0) - expect(windfallResponse.summary.totalWindfallPnlUsd).toBe(0) - expect(windfallResponse.summary.totalPnlUsd).toBe(0) - expect(windfallResponse.summary.totalEconomicGainUsd).toBe(0) - }) - - it('counts underlying token price appreciation for known-basis deposits in usd pnl', async () => { - vi.spyOn(Date, 'now').mockReturnValue(300_000) - - const response = await getSingleVaultResponse( - { - addressEvents: { - deposits: [ - { - id: 'known-deposit', - vaultAddress: VAULT, - chainId: 1, - blockNumber: 1, - blockTimestamp: 100, - logIndex: 1, - transactionHash: '0xknown-deposit', - transactionFrom: USER, - owner: USER, - sender: USER, - assets: '1000000000000000000', - shares: '1000000000000000000' - } - ], - withdrawals: [], - transfersIn: [], - transfersOut: [] - }, - transactionEvents: { - deposits: [], - withdrawals: [], - transfers: [] - } - }, - 'windfall', - { - metadata: { - tokenAddress: '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2', - symbol: 'WETH', - decimals: 18, - category: 'volatile' - }, - ppsTimeline: new Map([ - [100, 1], - [300, 1] - ]), - priceKey: 'ethereum:0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2', - prices: new Map([ - [100, 2000], - [300, 3000] - ]) - } - ) - - const vault = response.vaults[0] - - expect(vault.unrealizedPnlUnderlying).toBeCloseTo(0) - expect(vault.unrealizedPnlUsd).toBeCloseTo(1000) - expect(vault.totalPnlUsd).toBeCloseTo(1000) - expect(vault.totalEconomicGainUsd).toBeCloseTo(1000) - expect(response.summary.totalUnrealizedPnlUsd).toBeCloseTo(1000) - }) - - it('adds explicit underlying and known-basis usd fields to vault rows', async () => { - vi.spyOn(Date, 'now').mockReturnValue(300_000) - - const response = await getSingleVaultResponse( - { - addressEvents: { - deposits: [ - { - id: 'known-deposit', - vaultAddress: VAULT, - chainId: 1, - blockNumber: 1, - blockTimestamp: 100, - logIndex: 1, - transactionHash: '0xknown-deposit', - transactionFrom: USER, - owner: USER, - sender: USER, - assets: '1000000000000000000', - shares: '1000000000000000000' - } - ], - withdrawals: [], - transfersIn: [], - transfersOut: [] - }, - transactionEvents: { - deposits: [], - withdrawals: [], - transfers: [] - } - }, - 'windfall', - { - metadata: { - tokenAddress: '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2', - symbol: 'WETH', - decimals: 18 - }, - ppsTimeline: new Map([ - [100, 1], - [300, 1] - ]), - priceKey: 'ethereum:0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2', - prices: new Map([ - [100, 2000], - [300, 3000] - ]) - } - ) - - expect(response.vaults[0]).toMatchObject({ - currentUnderlying: 1, - vaultUnderlying: 1, - stakedUnderlying: 0, - currentKnownUnderlying: 1, - currentUnknownUnderlying: 0, - knownCostBasisUnderlying: 1, - knownCostBasisUsd: 2000 - }) - expect(response.vaults[0]?.metadata).toMatchObject({ - symbol: 'WETH', - decimals: 18, - assetDecimals: 18 - }) - }) - - it('returns drilldown lots, unknown-basis receipts, and journal rows', async () => { - vi.spyOn(Date, 'now').mockReturnValue(300_000) - - const response = await getSingleVaultDrilldownResponse(createTransferInContext(), 'windfall') - const vault = response.vaults[0] - - expect(vault.currentLots.vault).toHaveLength(2) - expect(vault.currentLots.staked).toHaveLength(0) - expect(vault.unknownTransferInEntries).toHaveLength(1) - expect(vault.unknownTransferInEntries[0]).toMatchObject({ - location: 'vault', - sharesFormatted: 100, - receiptValueUsd: 200 - }) - expect(vault.realizedEntries).toHaveLength(0) - expect(vault.journal).toHaveLength(2) - expect(vault.journal[0]).toMatchObject({ - txHash: '0xtransfer-in', - vaultLotsAfter: { - totalShares: '100000000', - unknownShares: '100000000' - } - }) - }) - - it('splits summary totals by stable and volatile vault categories', async () => { - const { summarizePnlVaults } = await import('./pnlValuation') - - const summary = summarizePnlVaults([ - { - chainId: 1, - vaultAddress: '0x1111111111111111111111111111111111111111', - stakingVaultAddress: null, - status: 'ok', - costBasisStatus: 'complete', - unknownTransferInPnlMode: 'windfall', - shares: '1', - sharesFormatted: 1, - vaultShares: '1', - vaultSharesFormatted: 1, - stakedShares: '0', - stakedSharesFormatted: 0, - knownCostBasisShares: '1', - unknownCostBasisShares: '0', - pricePerShare: 1, - tokenPrice: 1, - currentUnderlying: 10, - vaultUnderlying: 10, - stakedUnderlying: 0, - currentKnownUnderlying: 10, - currentUnknownUnderlying: 0, - knownCostBasisUnderlying: 10, - knownCostBasisUsd: 10, - currentValueUsd: 10, - vaultValueUsd: 10, - stakedValueUsd: 0, - unknownCostBasisValueUsd: 0, - windfallPnlUsd: 0, - realizedPnlUnderlying: 0, - realizedPnlUsd: 0, - unrealizedPnlUnderlying: 0, - unrealizedPnlUsd: 12, - totalPnlUsd: 12, - totalEconomicGainUsd: 12, - totalDepositedUnderlying: 0, - totalWithdrawnUnderlying: 0, - eventCounts: { - underlyingDeposits: 0, - underlyingWithdrawals: 0, - stakes: 0, - unstakes: 0, - rewardTransfersIn: 0, - externalTransfersIn: 0, - externalTransfersOut: 0, - migrationsIn: 0, - migrationsOut: 0, - unknownCostBasisTransfersIn: 0, - withdrawalsWithUnknownCostBasis: 0 - }, - metadata: { - symbol: 'USDC', - decimals: 6, - assetDecimals: 6, - tokenAddress: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', - category: 'stable' - } - }, - { - chainId: 1, - vaultAddress: '0x2222222222222222222222222222222222222222', - stakingVaultAddress: null, - status: 'ok', - costBasisStatus: 'complete', - unknownTransferInPnlMode: 'windfall', - shares: '1', - sharesFormatted: 1, - vaultShares: '1', - vaultSharesFormatted: 1, - stakedShares: '0', - stakedSharesFormatted: 0, - knownCostBasisShares: '1', - unknownCostBasisShares: '0', - pricePerShare: 1, - tokenPrice: 1, - currentUnderlying: 20, - vaultUnderlying: 20, - stakedUnderlying: 0, - currentKnownUnderlying: 20, - currentUnknownUnderlying: 0, - knownCostBasisUnderlying: 20, - knownCostBasisUsd: 17, - currentValueUsd: 20, - vaultValueUsd: 20, - stakedValueUsd: 0, - unknownCostBasisValueUsd: 0, - windfallPnlUsd: 3, - realizedPnlUnderlying: 0, - realizedPnlUsd: 0, - unrealizedPnlUnderlying: 0, - unrealizedPnlUsd: 5, - totalPnlUsd: 5, - totalEconomicGainUsd: 8, - totalDepositedUnderlying: 0, - totalWithdrawnUnderlying: 0, - eventCounts: { - underlyingDeposits: 0, - underlyingWithdrawals: 0, - stakes: 0, - unstakes: 0, - rewardTransfersIn: 0, - externalTransfersIn: 0, - externalTransfersOut: 0, - migrationsIn: 0, - migrationsOut: 0, - unknownCostBasisTransfersIn: 0, - withdrawalsWithUnknownCostBasis: 0 - }, - metadata: { - symbol: 'WETH', - decimals: 18, - assetDecimals: 18, - tokenAddress: '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2', - category: 'volatile' - } - } - ]) - - expect(summary.totalPnlUsd).toBe(17) - expect(summary.totalEconomicGainUsd).toBe(20) - expect(summary.byCategory.stable.totalPnlUsd).toBe(12) - expect(summary.byCategory.stable.totalEconomicGainUsd).toBe(12) - expect(summary.byCategory.volatile.totalPnlUsd).toBe(5) - expect(summary.byCategory.volatile.totalEconomicGainUsd).toBe(8) - }) -}) diff --git a/api/lib/holdings/services/pnl.test.ts b/api/lib/holdings/services/pnl.test.ts deleted file mode 100644 index 371d99a22..000000000 --- a/api/lib/holdings/services/pnl.test.ts +++ /dev/null @@ -1,1149 +0,0 @@ -import { describe, expect, it } from 'vitest' -import type { DepositEvent, TransferEvent, VaultMetadata, WithdrawEvent } from '../types' -import { - buildRawPnlEvents, - filterDirectInteractionLedgers, - filterRelevantHoldingsLedgers, - processRawPnlEvents -} from './pnl' - -const USER = '0x2222222222222222222222222222222222222222' -const ROUTER = '0x1111111111111111111111111111111111111111' -const MIGRATOR = '0x9327e2fdc57c7d70782f29ab46f6385afaf4503c' -const YEARN_4626_ROUTER = '0x1112dbcf805682e828606f74ab717abf4b4fd8de' -const ENSO_EXECUTOR = '0x4fe93ebc4ce6ae4f81601cc7ce7139023919e003' -const STAKING_VAULT = '0x622fa41799406b120f9a40da843d358b7b2cfee3' -const UNDERLYING_VAULT = '0xbe53a109b494e5c9f97b9cd39fe969be68bf6204' -const REWARD_DISTRIBUTOR = '0xb226c52eb411326cdb54824a88abafdaaff16d3d' -const REWARD_VAULT = '0xbf319ddc2edc1eb6fdf9910e39b37be221c8805f' -const KATANA_REWARD_DISTRIBUTOR = '0xa03e39cdeac8c2823a6edc80956207294807c20d' -const KATANA_REWARD_VAULT = '0x80c34bd3a3569e126e7055831036aa7b212cb159' -const KATANA_MULTISIG_REWARD_DISTRIBUTORS = [ - '0x67c912ff560951526bffdff66dfbd4df8ae23756', - '0x5480f3152748809495bd56c14eab4a622aa3a19b' -] as const -const KATANA_MULTISIG_REWARD_VAULTS = [ - '0x80c34bd3a3569e126e7055831036aa7b212cb159', - '0xe007ca01894c863d7898045ed5a3b4abf0b18f37', - '0xaa0362ecc584b985056e47812931270b99c91f9d', - '0x9a6bd7b6fd5c4f87eb66356441502fc7dcdd185b' -] as const -const SOURCE_MIGRATION_VAULT = '0x1635b506a88fbf428465ad65d00e8d6b6e5846c3' -const DESTINATION_MIGRATION_VAULT = '0x75a291f0232add37d72dd1dcff55b715755ecdee' -const ENDO_STEP_VAULT = '0xc56413869c6cdf96496f2b1ef801fedbdfa7ddb0' -const BOLD_TOKEN = '0x6440f144b7e50d6a8439336510312d2f54beb01d' -const YBOLD_VAULT = '0x9f4330700a36b29952869fac9b33f45eedd8a3d8' -const YSYBOLD_VAULT = '0x23346b04a7f55b8760e5860aa5a77383d63491cd' -const FAMILY_KEY = `1:${UNDERLYING_VAULT}` -const REWARD_FAMILY_KEY = `1:${REWARD_VAULT}` -const KATANA_REWARD_FAMILY_KEY = `747474:${KATANA_REWARD_VAULT}` -const YBOLD_FAMILY_KEY = `1:${YBOLD_VAULT}` -const YSYBOLD_FAMILY_KEY = `1:${YSYBOLD_VAULT}` -const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000' -const BOLD_STACK_METADATA = new Map([ - [ - YBOLD_FAMILY_KEY, - { - address: YBOLD_VAULT, - chainId: 1, - version: 'v3', - token: { - address: BOLD_TOKEN, - symbol: 'BOLD', - decimals: 18 - }, - decimals: 18 - } - ], - [ - YSYBOLD_FAMILY_KEY, - { - address: YSYBOLD_VAULT, - chainId: 1, - version: 'v3', - token: { - address: YBOLD_VAULT, - symbol: 'yBOLD', - decimals: 18 - }, - decimals: 18 - } - ] -]) - -function createDepositEvent(overrides: Partial): DepositEvent { - return { - id: 'deposit', - vaultAddress: UNDERLYING_VAULT, - chainId: 1, - blockNumber: 1, - blockTimestamp: 100, - logIndex: 1, - transactionHash: '0xdeposit', - transactionFrom: USER, - owner: USER, - sender: USER, - assets: '1000', - shares: '100', - ...overrides - } -} - -function createWithdrawEvent(overrides: Partial): WithdrawEvent { - return { - id: 'withdraw', - vaultAddress: UNDERLYING_VAULT, - chainId: 1, - blockNumber: 1, - blockTimestamp: 100, - logIndex: 1, - transactionHash: '0xwithdraw', - transactionFrom: USER, - owner: USER, - assets: '1000', - shares: '100', - ...overrides - } -} - -function createTransferEvent(overrides: Partial): TransferEvent { - return { - id: 'transfer', - vaultAddress: UNDERLYING_VAULT, - chainId: 1, - blockNumber: 1, - blockTimestamp: 100, - logIndex: 1, - transactionHash: '0xtransfer', - transactionFrom: USER, - sender: ROUTER, - receiver: USER, - value: '100', - ...overrides - } -} - -describe('processRawPnlEvents', () => { - it('moves a family lot from vault shares to staked shares on a direct stake', () => { - const underlyingDeposit = createDepositEvent() - const stakeDeposit = createDepositEvent({ - id: 'stake-deposit', - vaultAddress: STAKING_VAULT, - blockNumber: 2, - blockTimestamp: 200, - logIndex: 2, - transactionHash: '0xstake', - assets: '100', - shares: '100' - }) - const underlyingTransferOut = createTransferEvent({ - id: 'underlying-transfer-out', - blockNumber: 2, - blockTimestamp: 200, - logIndex: 1, - transactionHash: '0xstake', - vaultAddress: UNDERLYING_VAULT, - sender: USER, - receiver: STAKING_VAULT, - value: '100' - }) - - const ledger = processRawPnlEvents( - buildRawPnlEvents({ - addressEvents: { - deposits: [underlyingDeposit, stakeDeposit], - withdrawals: [], - transfersIn: [], - transfersOut: [underlyingTransferOut] - }, - transactionEvents: { - deposits: [underlyingDeposit, stakeDeposit], - withdrawals: [], - transfers: [underlyingTransferOut] - } - }), - USER - ).get(FAMILY_KEY) - - expect(ledger).toBeDefined() - expect(ledger?.vaultLots).toEqual([]) - expect(ledger?.stakedLots).toEqual([{ shares: 100n, costBasis: 1000n, acquiredAt: 100 }]) - expect(ledger?.realizedEntries).toEqual([]) - expect(ledger?.totalDepositedAssets).toBe(1000n) - expect(ledger?.eventCounts.underlyingDeposits).toBe(1) - expect(ledger?.eventCounts.stakes).toBe(1) - }) - - it('moves a family lot from staked shares back to vault shares on unstake without realizing pnl', () => { - const underlyingDeposit = createDepositEvent() - const stakeDeposit = createDepositEvent({ - id: 'stake-deposit', - vaultAddress: STAKING_VAULT, - blockNumber: 2, - blockTimestamp: 200, - logIndex: 2, - transactionHash: '0xstake', - assets: '100', - shares: '100' - }) - const underlyingTransferOut = createTransferEvent({ - id: 'underlying-transfer-out', - blockNumber: 2, - blockTimestamp: 200, - logIndex: 1, - transactionHash: '0xstake', - vaultAddress: UNDERLYING_VAULT, - sender: USER, - receiver: STAKING_VAULT, - value: '100' - }) - const stakeWithdraw = createWithdrawEvent({ - id: 'stake-withdraw', - vaultAddress: STAKING_VAULT, - blockNumber: 3, - blockTimestamp: 300, - logIndex: 1, - transactionHash: '0xunstake', - owner: USER, - assets: '100', - shares: '100' - }) - const underlyingTransferIn = createTransferEvent({ - id: 'underlying-transfer-in', - blockNumber: 3, - blockTimestamp: 300, - logIndex: 2, - transactionHash: '0xunstake', - vaultAddress: UNDERLYING_VAULT, - sender: STAKING_VAULT, - receiver: USER, - value: '100' - }) - - const ledger = processRawPnlEvents( - buildRawPnlEvents({ - addressEvents: { - deposits: [underlyingDeposit, stakeDeposit], - withdrawals: [stakeWithdraw], - transfersIn: [underlyingTransferIn], - transfersOut: [underlyingTransferOut] - }, - transactionEvents: { - deposits: [underlyingDeposit, stakeDeposit], - withdrawals: [stakeWithdraw], - transfers: [underlyingTransferOut, underlyingTransferIn] - } - }), - USER - ).get(FAMILY_KEY) - - expect(ledger).toBeDefined() - expect(ledger?.vaultLots).toEqual([{ shares: 100n, costBasis: 1000n, acquiredAt: 100 }]) - expect(ledger?.stakedLots).toEqual([]) - expect(ledger?.realizedEntries).toEqual([]) - expect(ledger?.eventCounts.stakes).toBe(1) - expect(ledger?.eventCounts.unstakes).toBe(1) - }) - - it('uses the underlying vault deposit as cost basis for router stakes into staking vaults', () => { - const underlyingDeposit = createDepositEvent({ - id: 'router-underlying-deposit', - owner: ROUTER, - sender: ROUTER, - transactionHash: '0xrouter-stake', - blockTimestamp: 200, - blockNumber: 2, - assets: '1000', - shares: '900' - }) - const stakingTransferIn = createTransferEvent({ - id: 'router-staking-transfer-in', - vaultAddress: STAKING_VAULT, - transactionHash: '0xrouter-stake', - blockTimestamp: 200, - blockNumber: 2, - sender: ROUTER, - receiver: USER, - value: '900' - }) - - const ledger = processRawPnlEvents( - buildRawPnlEvents({ - addressEvents: { - deposits: [], - withdrawals: [], - transfersIn: [stakingTransferIn], - transfersOut: [] - }, - transactionEvents: { - deposits: [underlyingDeposit], - withdrawals: [], - transfers: [stakingTransferIn] - } - }), - USER - ).get(FAMILY_KEY) - - expect(ledger).toBeDefined() - expect(ledger?.vaultLots).toEqual([]) - expect(ledger?.stakedLots).toEqual([{ shares: 900n, costBasis: 1000n, acquiredAt: 200 }]) - expect(ledger?.unknownCostBasisTransferInCount).toBe(0) - expect(ledger?.eventCounts.underlyingDeposits).toBe(1) - expect(ledger?.eventCounts.externalTransfersIn).toBe(0) - }) - - it('uses owner deposits even when the transaction sender is an external distributor', () => { - const distributorDeposit = createDepositEvent({ - id: 'distributor-deposit', - transactionHash: '0xdistributor', - transactionFrom: '0x2222222222222222222222222222222222222222', - sender: '0x3333333333333333333333333333333333333333', - owner: USER, - assets: '1000', - shares: '900' - }) - - const ledger = processRawPnlEvents( - buildRawPnlEvents({ - addressEvents: { - deposits: [distributorDeposit], - withdrawals: [], - transfersIn: [], - transfersOut: [] - }, - transactionEvents: { - deposits: [], - withdrawals: [], - transfers: [] - } - }), - USER - ).get(FAMILY_KEY) - - expect(ledger).toBeDefined() - expect(ledger?.vaultLots).toEqual([{ shares: 900n, costBasis: 1000n, acquiredAt: 100 }]) - expect(ledger?.stakedLots).toEqual([]) - expect(ledger?.totalDepositedAssets).toBe(1000n) - expect(ledger?.eventCounts.underlyingDeposits).toBe(1) - }) - - it('classifies known reward distributor receipts as zero-basis rewards instead of unknown transfer-ins', () => { - const rewardTransferIn = createTransferEvent({ - id: 'reward-transfer-in', - transactionHash: '0xreward', - vaultAddress: REWARD_VAULT, - sender: REWARD_DISTRIBUTOR, - receiver: USER, - value: '100' - }) - - const ledger = processRawPnlEvents( - buildRawPnlEvents({ - addressEvents: { - deposits: [], - withdrawals: [], - transfersIn: [rewardTransferIn], - transfersOut: [] - }, - transactionEvents: { - deposits: [], - withdrawals: [], - transfers: [rewardTransferIn] - } - }), - USER - ).get(REWARD_FAMILY_KEY) - - expect(ledger).toBeDefined() - expect(ledger?.vaultLots).toEqual([{ shares: 100n, costBasis: 0n, acquiredAt: 100 }]) - expect(ledger?.stakedLots).toEqual([]) - expect(ledger?.unknownCostBasisTransferInCount).toBe(0) - expect(ledger?.unknownTransferInEntries).toEqual([]) - expect(ledger?.rewardTransferInEntries).toEqual([ - { - timestamp: 100, - shares: 100n, - location: 'vault', - distributor: REWARD_DISTRIBUTOR - } - ]) - expect(ledger?.eventCounts.rewardTransfersIn).toBe(1) - expect(ledger?.eventCounts.externalTransfersIn).toBe(0) - expect(ledger?.debugJournal.at(-1)?.view).toBe('reward_in_vault') - }) - - it('classifies known Katana reward distributor receipts as zero-basis rewards instead of unknown transfer-ins', () => { - const rewardTransferIn = createTransferEvent({ - id: 'katana-reward-transfer-in', - chainId: 747474, - transactionHash: '0xkatana-reward', - vaultAddress: KATANA_REWARD_VAULT, - sender: KATANA_REWARD_DISTRIBUTOR, - receiver: USER, - value: '8523506' - }) - - const ledger = processRawPnlEvents( - buildRawPnlEvents({ - addressEvents: { - deposits: [], - withdrawals: [], - transfersIn: [rewardTransferIn], - transfersOut: [] - }, - transactionEvents: { - deposits: [], - withdrawals: [], - transfers: [rewardTransferIn] - } - }), - USER - ).get(KATANA_REWARD_FAMILY_KEY) - - expect(ledger).toBeDefined() - expect(ledger?.vaultLots).toEqual([{ shares: 8523506n, costBasis: 0n, acquiredAt: 100 }]) - expect(ledger?.unknownCostBasisTransferInCount).toBe(0) - expect(ledger?.unknownTransferInEntries).toEqual([]) - expect(ledger?.rewardTransferInEntries).toEqual([ - { - timestamp: 100, - shares: 8523506n, - location: 'vault', - distributor: KATANA_REWARD_DISTRIBUTOR - } - ]) - expect(ledger?.eventCounts.rewardTransfersIn).toBe(1) - expect(ledger?.eventCounts.externalTransfersIn).toBe(0) - expect(ledger?.debugJournal.at(-1)?.view).toBe('reward_in_vault') - }) - - it.each( - KATANA_MULTISIG_REWARD_DISTRIBUTORS.flatMap((distributor) => - KATANA_MULTISIG_REWARD_VAULTS.map((vaultAddress) => [distributor, vaultAddress] as const) - ) - )('classifies Katana multisig reward receipts from %s for %s as zero-basis rewards instead of unknown transfer-ins', (distributor, vaultAddress) => { - const rewardTransferIn = createTransferEvent({ - id: `katana-multisig-reward-transfer-in-${distributor}-${vaultAddress}`, - chainId: 747474, - transactionHash: '0xkatana-multisig-reward', - vaultAddress, - sender: distributor, - receiver: USER, - value: '100' - }) - - const ledger = processRawPnlEvents( - buildRawPnlEvents({ - addressEvents: { - deposits: [], - withdrawals: [], - transfersIn: [rewardTransferIn], - transfersOut: [] - }, - transactionEvents: { - deposits: [], - withdrawals: [], - transfers: [rewardTransferIn] - } - }), - USER - ).get(`747474:${vaultAddress}`) - - expect(ledger).toBeDefined() - expect(ledger?.vaultLots).toEqual([{ shares: 100n, costBasis: 0n, acquiredAt: 100 }]) - expect(ledger?.unknownCostBasisTransferInCount).toBe(0) - expect(ledger?.unknownTransferInEntries).toEqual([]) - expect(ledger?.rewardTransferInEntries).toEqual([ - { - timestamp: 100, - shares: 100n, - location: 'vault', - distributor - } - ]) - expect(ledger?.eventCounts.rewardTransfersIn).toBe(1) - expect(ledger?.eventCounts.externalTransfersIn).toBe(0) - expect(ledger?.debugJournal.at(-1)?.view).toBe('reward_in_vault') - }) - - it('realizes pnl from underlying vault withdrawals when staked shares exit through a router', () => { - const underlyingDeposit = createDepositEvent({ - id: 'router-underlying-deposit', - owner: ROUTER, - sender: ROUTER, - transactionHash: '0xrouter-stake', - blockTimestamp: 200, - blockNumber: 2, - assets: '1000', - shares: '900' - }) - const stakingTransferIn = createTransferEvent({ - id: 'router-staking-transfer-in', - vaultAddress: STAKING_VAULT, - transactionHash: '0xrouter-stake', - blockTimestamp: 200, - blockNumber: 2, - sender: ROUTER, - receiver: USER, - value: '900' - }) - const stakingTransferOut = createTransferEvent({ - id: 'router-staking-transfer-out', - vaultAddress: STAKING_VAULT, - transactionHash: '0xrouter-exit', - blockTimestamp: 300, - blockNumber: 3, - sender: USER, - receiver: ROUTER, - value: '900' - }) - const underlyingWithdraw = createWithdrawEvent({ - id: 'router-underlying-withdraw', - owner: ROUTER, - transactionHash: '0xrouter-exit', - blockTimestamp: 300, - blockNumber: 3, - assets: '1100', - shares: '900' - }) - - const ledger = processRawPnlEvents( - buildRawPnlEvents({ - addressEvents: { - deposits: [], - withdrawals: [], - transfersIn: [stakingTransferIn], - transfersOut: [stakingTransferOut] - }, - transactionEvents: { - deposits: [underlyingDeposit], - withdrawals: [underlyingWithdraw], - transfers: [stakingTransferIn, stakingTransferOut] - } - }), - USER - ).get(FAMILY_KEY) - - expect(ledger).toBeDefined() - expect(ledger?.vaultLots).toEqual([]) - expect(ledger?.stakedLots).toEqual([]) - expect(ledger?.realizedEntries).toEqual([ - { - timestamp: 300, - proceedsAssets: 1100n, - basisAssets: 1000n, - pnlAssets: 100n, - consumedLots: [{ shares: 900n, costBasis: 1000n, acquiredAt: 200 }] - } - ]) - expect(ledger?.totalWithdrawnAssets).toBe(1100n) - expect(ledger?.eventCounts.underlyingWithdrawals).toBe(1) - expect(ledger?.withdrawalsWithUnknownCostBasis).toBe(0) - }) - - it('ignores tx-scoped family withdrawals when the user has no address-scoped activity on that family', () => { - const internalWithdraw = createWithdrawEvent({ - id: 'internal-withdraw', - transactionHash: '0xinternal-withdraw', - transactionFrom: USER, - owner: ROUTER, - assets: '1100', - shares: '900' - }) - - const ledgers = processRawPnlEvents( - buildRawPnlEvents({ - addressEvents: { - deposits: [], - withdrawals: [], - transfersIn: [], - transfersOut: [] - }, - transactionEvents: { - deposits: [], - withdrawals: [internalWithdraw], - transfers: [] - } - }), - USER - ) - - expect(ledgers.get(FAMILY_KEY)).toBeUndefined() - }) - - it('drops transfer-only families from direct-interaction pnl ledgers', () => { - const transferOnlyLedger = processRawPnlEvents( - buildRawPnlEvents({ - addressEvents: { - deposits: [], - withdrawals: [], - transfersIn: [createTransferEvent()], - transfersOut: [] - }, - transactionEvents: { - deposits: [], - withdrawals: [], - transfers: [createTransferEvent()] - } - }), - USER - ) - - const filteredLedgers = filterDirectInteractionLedgers(transferOnlyLedger) - - expect(transferOnlyLedger.get(FAMILY_KEY)).toBeDefined() - expect(filteredLedgers.get(FAMILY_KEY)).toBeUndefined() - }) - - it('keeps transfer-only families with current shares in the holdings ledger set', () => { - const transferOnlyLedger = processRawPnlEvents( - buildRawPnlEvents({ - addressEvents: { - deposits: [], - withdrawals: [], - transfersIn: [createTransferEvent()], - transfersOut: [] - }, - transactionEvents: { - deposits: [], - withdrawals: [], - transfers: [createTransferEvent()] - } - }), - USER - ) - - const filteredLedgers = filterRelevantHoldingsLedgers(transferOnlyLedger) - - expect(transferOnlyLedger.get(FAMILY_KEY)).toBeDefined() - expect(filteredLedgers.get(FAMILY_KEY)).toBeDefined() - }) - - it('treats same-vault withdraw and redeposit in the same tx as a basis rollover', () => { - const initialDeposit = createDepositEvent({ - id: 'initial-deposit', - transactionHash: '0xinitial', - assets: '1000', - shares: '100' - }) - const rolloverTransferOut = createTransferEvent({ - id: 'rollover-transfer-out', - transactionHash: '0xrollover', - blockNumber: 2, - blockTimestamp: 200, - logIndex: 1, - sender: USER, - receiver: '0x0000000000000000000000000000000000000000', - value: '100' - }) - const rolloverWithdraw = createWithdrawEvent({ - id: 'rollover-withdraw', - transactionHash: '0xrollover', - blockNumber: 2, - blockTimestamp: 200, - logIndex: 2, - owner: USER, - assets: '1100', - shares: '100' - }) - const rolloverTransferIn = createTransferEvent({ - id: 'rollover-transfer-in', - transactionHash: '0xrollover', - blockNumber: 2, - blockTimestamp: 200, - logIndex: 3, - sender: '0x0000000000000000000000000000000000000000', - receiver: USER, - value: '99' - }) - const rolloverDeposit = createDepositEvent({ - id: 'rollover-deposit', - transactionHash: '0xrollover', - blockNumber: 2, - blockTimestamp: 200, - logIndex: 4, - owner: USER, - sender: ROUTER, - assets: '1100', - shares: '99' - }) - - const ledger = processRawPnlEvents( - buildRawPnlEvents({ - addressEvents: { - deposits: [initialDeposit, rolloverDeposit], - withdrawals: [rolloverWithdraw], - transfersIn: [rolloverTransferIn], - transfersOut: [rolloverTransferOut] - }, - transactionEvents: { - deposits: [initialDeposit, rolloverDeposit], - withdrawals: [rolloverWithdraw], - transfers: [rolloverTransferOut, rolloverTransferIn] - } - }), - USER - ).get(FAMILY_KEY) - - expect(ledger).toBeDefined() - expect(ledger?.vaultLots).toEqual([{ shares: 99n, costBasis: 1000n, acquiredAt: 100 }]) - expect(ledger?.stakedLots).toEqual([]) - expect(ledger?.realizedEntries).toEqual([]) - expect(ledger?.unknownCostBasisTransferInCount).toBe(0) - expect(ledger?.withdrawalsWithUnknownCostBasis).toBe(0) - expect(ledger?.totalDepositedAssets).toBe(1000n) - expect(ledger?.totalWithdrawnAssets).toBe(0n) - expect(ledger?.eventCounts.underlyingDeposits).toBe(1) - expect(ledger?.eventCounts.underlyingWithdrawals).toBe(0) - expect(ledger?.debugJournal.at(-1)?.view).toBe('same_vault_rollover->vault') - }) - - it('rolls known source basis into the destination vault on a known migrator tx', () => { - const sourceFamilyKey = `1:${SOURCE_MIGRATION_VAULT}` - const destinationFamilyKey = `1:${DESTINATION_MIGRATION_VAULT}` - const sourceDeposit = createDepositEvent({ - id: 'source-deposit', - transactionHash: '0xsource-deposit', - vaultAddress: SOURCE_MIGRATION_VAULT, - assets: '1000', - shares: '100' - }) - const migrateOut = createTransferEvent({ - id: 'migrate-out', - transactionHash: '0xmigrate', - blockNumber: 2, - blockTimestamp: 200, - logIndex: 1, - vaultAddress: SOURCE_MIGRATION_VAULT, - sender: USER, - receiver: MIGRATOR, - value: '100' - }) - const migrateBurn = createTransferEvent({ - id: 'migrate-burn', - transactionHash: '0xmigrate', - blockNumber: 2, - blockTimestamp: 200, - logIndex: 2, - vaultAddress: SOURCE_MIGRATION_VAULT, - sender: MIGRATOR, - receiver: ZERO_ADDRESS, - value: '100' - }) - const destinationMint = createTransferEvent({ - id: 'destination-mint', - transactionHash: '0xmigrate', - blockNumber: 2, - blockTimestamp: 200, - logIndex: 3, - vaultAddress: DESTINATION_MIGRATION_VAULT, - sender: ZERO_ADDRESS, - receiver: USER, - value: '80' - }) - const destinationDeposit = createDepositEvent({ - id: 'destination-deposit', - transactionHash: '0xmigrate', - blockNumber: 2, - blockTimestamp: 200, - logIndex: 4, - vaultAddress: DESTINATION_MIGRATION_VAULT, - owner: USER, - sender: USER, - assets: '1100', - shares: '80' - }) - - const ledgers = processRawPnlEvents( - buildRawPnlEvents({ - addressEvents: { - deposits: [sourceDeposit, destinationDeposit], - withdrawals: [], - transfersIn: [destinationMint], - transfersOut: [migrateOut] - }, - transactionEvents: { - deposits: [sourceDeposit, destinationDeposit], - withdrawals: [], - transfers: [migrateOut, migrateBurn, destinationMint] - } - }), - USER - ) - const filteredLedgers = filterDirectInteractionLedgers(ledgers) - const sourceLedger = ledgers.get(sourceFamilyKey) - const destinationLedger = ledgers.get(destinationFamilyKey) - - expect(sourceLedger?.vaultLots).toEqual([]) - expect(sourceLedger?.realizedEntries).toEqual([]) - expect(sourceLedger?.eventCounts.migrationsOut).toBe(1) - expect(destinationLedger?.vaultLots).toEqual([{ shares: 80n, costBasis: 1000n, acquiredAt: 100 }]) - expect(destinationLedger?.totalDepositedAssets).toBe(0n) - expect(destinationLedger?.realizedEntries).toEqual([]) - expect(destinationLedger?.eventCounts.migrationsIn).toBe(1) - expect(destinationLedger?.debugJournal.at(-1)?.view).toBe('migrate_in->vault') - expect(filteredLedgers.get(destinationFamilyKey)).toBeDefined() - }) - - it('marks migrated destination shares as unknown when the source vault only had a mint transfer', () => { - const sourceFamilyKey = `1:${SOURCE_MIGRATION_VAULT}` - const destinationFamilyKey = `1:${DESTINATION_MIGRATION_VAULT}` - const sourceMint = createTransferEvent({ - id: 'source-mint', - transactionHash: '0xsource-mint', - vaultAddress: SOURCE_MIGRATION_VAULT, - sender: ZERO_ADDRESS, - receiver: USER, - value: '158' - }) - const migrateOut = createTransferEvent({ - id: 'migrate-out', - transactionHash: '0xmigrate-unknown', - blockNumber: 2, - blockTimestamp: 200, - logIndex: 1, - vaultAddress: SOURCE_MIGRATION_VAULT, - sender: USER, - receiver: MIGRATOR, - value: '158' - }) - const migrateBurn = createTransferEvent({ - id: 'migrate-burn', - transactionHash: '0xmigrate-unknown', - blockNumber: 2, - blockTimestamp: 200, - logIndex: 2, - vaultAddress: SOURCE_MIGRATION_VAULT, - sender: MIGRATOR, - receiver: ZERO_ADDRESS, - value: '158' - }) - const destinationMint = createTransferEvent({ - id: 'destination-mint', - transactionHash: '0xmigrate-unknown', - blockNumber: 2, - blockTimestamp: 200, - logIndex: 3, - vaultAddress: DESTINATION_MIGRATION_VAULT, - sender: ZERO_ADDRESS, - receiver: USER, - value: '175' - }) - const destinationDeposit = createDepositEvent({ - id: 'destination-deposit', - transactionHash: '0xmigrate-unknown', - blockNumber: 2, - blockTimestamp: 200, - logIndex: 4, - vaultAddress: DESTINATION_MIGRATION_VAULT, - owner: USER, - sender: USER, - assets: '304', - shares: '175' - }) - - const ledgers = processRawPnlEvents( - buildRawPnlEvents({ - addressEvents: { - deposits: [destinationDeposit], - withdrawals: [], - transfersIn: [sourceMint, destinationMint], - transfersOut: [migrateOut] - }, - transactionEvents: { - deposits: [destinationDeposit], - withdrawals: [], - transfers: [sourceMint, migrateOut, migrateBurn, destinationMint] - } - }), - USER - ) - const filteredLedgers = filterDirectInteractionLedgers(ledgers) - const sourceLedger = ledgers.get(sourceFamilyKey) - const destinationLedger = ledgers.get(destinationFamilyKey) - - expect(sourceLedger?.unmatchedTransferOutShares).toBe(158n) - expect(destinationLedger?.vaultLots).toEqual([{ shares: 175n, costBasis: null, acquiredAt: 200 }]) - expect(destinationLedger?.unknownCostBasisTransferInCount).toBe(1) - expect(destinationLedger?.unknownCostBasisTransferInShares).toBe(175n) - expect(destinationLedger?.totalDepositedAssets).toBe(0n) - expect(destinationLedger?.eventCounts.migrationsIn).toBe(1) - expect(filteredLedgers.get(sourceFamilyKey)).toBeUndefined() - expect(filteredLedgers.get(destinationFamilyKey)).toBeDefined() - }) - - it('treats the Yearn 4626 router as a valid migration router', () => { - const destinationFamilyKey = `1:${DESTINATION_MIGRATION_VAULT}` - const sourceDeposit = createDepositEvent({ - id: 'source-deposit-router', - transactionHash: '0xsource-deposit-router', - vaultAddress: SOURCE_MIGRATION_VAULT, - assets: '1000', - shares: '100' - }) - const migrateOut = createTransferEvent({ - id: 'migrate-out-router', - transactionHash: '0xmigrate-router', - blockNumber: 2, - blockTimestamp: 200, - logIndex: 1, - vaultAddress: SOURCE_MIGRATION_VAULT, - sender: USER, - receiver: YEARN_4626_ROUTER, - value: '100' - }) - const migrateBurn = createTransferEvent({ - id: 'migrate-burn-router', - transactionHash: '0xmigrate-router', - blockNumber: 2, - blockTimestamp: 200, - logIndex: 2, - vaultAddress: SOURCE_MIGRATION_VAULT, - sender: YEARN_4626_ROUTER, - receiver: ZERO_ADDRESS, - value: '100' - }) - const destinationMint = createTransferEvent({ - id: 'destination-mint-router', - transactionHash: '0xmigrate-router', - blockNumber: 2, - blockTimestamp: 200, - logIndex: 3, - vaultAddress: DESTINATION_MIGRATION_VAULT, - sender: ZERO_ADDRESS, - receiver: USER, - value: '80' - }) - const destinationDeposit = createDepositEvent({ - id: 'destination-deposit-router', - transactionHash: '0xmigrate-router', - blockNumber: 2, - blockTimestamp: 200, - logIndex: 4, - vaultAddress: DESTINATION_MIGRATION_VAULT, - owner: USER, - sender: USER, - assets: '1100', - shares: '80' - }) - - const ledgers = processRawPnlEvents( - buildRawPnlEvents({ - addressEvents: { - deposits: [sourceDeposit, destinationDeposit], - withdrawals: [], - transfersIn: [destinationMint], - transfersOut: [migrateOut] - }, - transactionEvents: { - deposits: [sourceDeposit, destinationDeposit], - withdrawals: [], - transfers: [migrateOut, migrateBurn, destinationMint] - } - }), - USER - ) - - expect(ledgers.get(destinationFamilyKey)?.vaultLots).toEqual([{ shares: 80n, costBasis: 1000n, acquiredAt: 100 }]) - expect(ledgers.get(destinationFamilyKey)?.eventCounts.migrationsIn).toBe(1) - }) - - it('rolls basis through Enso-mediated cross-family vault rollovers', () => { - const sourceFamilyKey = `1:${SOURCE_MIGRATION_VAULT}` - const destinationFamilyKey = `1:${DESTINATION_MIGRATION_VAULT}` - const sourceDeposit = createDepositEvent({ - id: 'source-deposit-enso', - transactionHash: '0xsource-deposit-enso', - vaultAddress: SOURCE_MIGRATION_VAULT, - assets: '1000', - shares: '100' - }) - const migrateOut = createTransferEvent({ - id: 'migrate-out-enso', - transactionHash: '0xenso-rollover', - blockNumber: 2, - blockTimestamp: 200, - logIndex: 1, - vaultAddress: SOURCE_MIGRATION_VAULT, - sender: USER, - receiver: ENSO_EXECUTOR, - value: '100' - }) - const migrateBurn = createTransferEvent({ - id: 'migrate-burn-enso', - transactionHash: '0xenso-rollover', - blockNumber: 2, - blockTimestamp: 200, - logIndex: 2, - vaultAddress: SOURCE_MIGRATION_VAULT, - sender: ENSO_EXECUTOR, - receiver: ZERO_ADDRESS, - value: '100' - }) - const sourceWithdraw = createWithdrawEvent({ - id: 'source-withdraw-enso', - transactionHash: '0xenso-rollover', - blockNumber: 2, - blockTimestamp: 200, - logIndex: 3, - vaultAddress: SOURCE_MIGRATION_VAULT, - owner: ENSO_EXECUTOR, - assets: '1100', - shares: '100' - }) - const ensoStepBurn = createTransferEvent({ - id: 'enso-step-burn', - transactionHash: '0xenso-rollover', - blockNumber: 2, - blockTimestamp: 200, - logIndex: 3, - vaultAddress: ENDO_STEP_VAULT, - sender: DESTINATION_MIGRATION_VAULT, - receiver: ZERO_ADDRESS, - value: '103' - }) - const ensoStepWithdraw = createWithdrawEvent({ - id: 'enso-step-withdraw', - transactionHash: '0xenso-rollover', - blockNumber: 2, - blockTimestamp: 200, - logIndex: 4, - vaultAddress: ENDO_STEP_VAULT, - owner: DESTINATION_MIGRATION_VAULT, - assets: '1100', - shares: '103' - }) - const destinationMint = createTransferEvent({ - id: 'destination-mint-enso', - transactionHash: '0xenso-rollover', - blockNumber: 2, - blockTimestamp: 200, - logIndex: 5, - vaultAddress: DESTINATION_MIGRATION_VAULT, - sender: ZERO_ADDRESS, - receiver: USER, - value: '81' - }) - const destinationDeposit = createDepositEvent({ - id: 'destination-deposit-enso', - transactionHash: '0xenso-rollover', - blockNumber: 2, - blockTimestamp: 200, - logIndex: 6, - vaultAddress: DESTINATION_MIGRATION_VAULT, - owner: USER, - sender: ENSO_EXECUTOR, - assets: '1100', - shares: '80' - }) - - const ledgers = processRawPnlEvents( - buildRawPnlEvents({ - addressEvents: { - deposits: [sourceDeposit, destinationDeposit], - withdrawals: [], - transfersIn: [destinationMint], - transfersOut: [migrateOut] - }, - transactionEvents: { - deposits: [sourceDeposit, destinationDeposit], - withdrawals: [sourceWithdraw, ensoStepWithdraw], - transfers: [migrateOut, migrateBurn, ensoStepBurn, destinationMint] - } - }), - USER - ) - - expect(ledgers.get(sourceFamilyKey)?.vaultLots).toEqual([]) - expect(ledgers.get(sourceFamilyKey)?.eventCounts.migrationsOut).toBe(1) - expect(ledgers.get(destinationFamilyKey)?.vaultLots).toEqual([{ shares: 81n, costBasis: 1000n, acquiredAt: 100 }]) - expect(ledgers.get(destinationFamilyKey)?.unknownTransferInEntries).toEqual([]) - expect(ledgers.get(destinationFamilyKey)?.eventCounts.migrationsIn).toBe(1) - expect(ledgers.get(destinationFamilyKey)?.debugJournal.at(-1)?.view).toBe('migrate_in->vault') - }) - - it('rolls basis from ysyBOLD withdrawals into yBOLD receipts', () => { - const outerDepositMint = createTransferEvent({ - id: 'ysybold-mint', - transactionHash: '0xysybold-deposit', - vaultAddress: YSYBOLD_VAULT, - sender: ZERO_ADDRESS, - receiver: USER, - value: '94' - }) - const outerDeposit = createDepositEvent({ - id: 'ysybold-deposit', - transactionHash: '0xysybold-deposit', - vaultAddress: YSYBOLD_VAULT, - owner: USER, - sender: USER, - assets: '100', - shares: '94' - }) - const outerBurn = createTransferEvent({ - id: 'ysybold-burn', - transactionHash: '0xysybold-withdraw', - blockNumber: 2, - blockTimestamp: 200, - logIndex: 1, - vaultAddress: YSYBOLD_VAULT, - sender: USER, - receiver: ZERO_ADDRESS, - value: '94' - }) - const innerTransfer = createTransferEvent({ - id: 'ybold-transfer-in', - transactionHash: '0xysybold-withdraw', - blockNumber: 2, - blockTimestamp: 200, - logIndex: 2, - vaultAddress: YBOLD_VAULT, - sender: YSYBOLD_VAULT, - receiver: USER, - value: '100' - }) - const outerWithdraw = createWithdrawEvent({ - id: 'ysybold-withdraw', - transactionHash: '0xysybold-withdraw', - blockNumber: 2, - blockTimestamp: 200, - logIndex: 3, - vaultAddress: YSYBOLD_VAULT, - owner: USER, - assets: '100', - shares: '94' - }) - - const ledgers = processRawPnlEvents( - buildRawPnlEvents({ - addressEvents: { - deposits: [outerDeposit], - withdrawals: [outerWithdraw], - transfersIn: [outerDepositMint, innerTransfer], - transfersOut: [outerBurn] - }, - transactionEvents: { - deposits: [outerDeposit], - withdrawals: [outerWithdraw], - transfers: [outerDepositMint, outerBurn, innerTransfer] - } - }), - USER, - BOLD_STACK_METADATA - ) - const outerLedger = ledgers.get(YSYBOLD_FAMILY_KEY) - const innerLedger = ledgers.get(YBOLD_FAMILY_KEY) - - expect(outerLedger?.vaultLots).toEqual([]) - expect(outerLedger?.realizedEntries).toEqual([]) - expect(outerLedger?.unknownWithdrawalEntries).toEqual([]) - expect(outerLedger?.eventCounts.underlyingDeposits).toBe(1) - expect(outerLedger?.eventCounts.underlyingWithdrawals).toBe(1) - expect(outerLedger?.eventCounts.migrationsOut).toBe(1) - expect(outerLedger?.totalWithdrawnAssets).toBe(100n) - expect(innerLedger?.vaultLots).toEqual([{ shares: 100n, costBasis: 100n, acquiredAt: 100 }]) - expect(innerLedger?.unknownTransferInEntries).toEqual([]) - expect(innerLedger?.eventCounts.migrationsIn).toBe(1) - expect(innerLedger?.debugJournal.at(-1)?.view).toBe('migrate_in->vault') - }) -}) diff --git a/api/lib/holdings/services/pnl.ts b/api/lib/holdings/services/pnl.ts deleted file mode 100644 index 9848ad78e..000000000 --- a/api/lib/holdings/services/pnl.ts +++ /dev/null @@ -1,2777 +0,0 @@ -import type { DepositEvent, TransferEvent, VaultMetadata, WithdrawEvent } from '../types' -import { enrichRawPnlEventsWithCowTradeAcquisitions } from './cow' -import { debugLog, debugTable, getHoldingsDebugFilters } from './debug' -import { - fetchHistoricalPricesForTokenTimestamps, - getChainPrefix, - getPriceAtTimestamp, - type THistoricalPriceRequest -} from './defillama' -import { - fetchRawUserPnlEvents, - type HoldingsEventFetchType, - type HoldingsEventPaginationMode, - type RawPnlEventContext, - type VaultVersion -} from './graphql' -import { fetchMultipleVaultsPPS, getPPS } from './kong' -import { - deriveNestedVaultAssetPriceData, - expandNestedVaultAssetPriceRequests, - getAssetVaultMetadataLookupIdentifiers, - getNestedVaultPpsIdentifiersFromPriceRequests, - mergeVaultIdentifiers -} from './nestedVaultPrices' -import { - formatAmount, - isKnownCompatibleAssetVaultRollover, - isKnownZeroBasisRewardDistribution, - KNOWN_VAULT_ROLLOVER_INTERMEDIARIES, - lowerCaseAddress, - minBigInt, - negativeBigIntMagnitude, - positiveBigInt, - sumKnownCostBasis, - sumShares, - toVaultKey, - ZERO, - ZERO_ADDRESS -} from './pnlShared' - -export type { - HoldingsPnLDrilldownResponse, - HoldingsPnLDrilldownVault, - HoldingsPnLResponse, - HoldingsPnLVault, - UnknownTransferInPnlMode -} from './pnlTypes' - -import type { - FamilyPnlLedger, - HoldingsPnLDrilldownResponse, - HoldingsPnLDrilldownVault, - HoldingsPnLResponse, - HoldingsPnLVault, - TLocation, - TLot, - TLotSummary, - TPnlDebugJournalRow, - TRawPnlEvent, - TRawScopes, - UnknownTransferInPnlMode -} from './pnlTypes' -import { - applyUnknownTransferInModeAdjustment, - createEmptyHoldingsPnlResponse, - createMissingMetadataPnlVault, - summarizePnlVaults, - toHoldingsPnlEventCounts -} from './pnlValuation' -import { getFamilyVaultAddress, getStakingVaultAddress, isStakingVault } from './staking' -import { fetchMultipleVaultsMetadata } from './vaults' - -function createLot(shares: bigint, costBasis: bigint | null, acquiredAt?: number): TLot { - return acquiredAt === undefined ? { shares, costBasis } : { shares, costBasis, acquiredAt } -} - -function cloneLots(lots: TLot[]): TLot[] { - return lots.map((lot) => createLot(lot.shares, lot.costBasis, lot.acquiredAt)) -} - -function summarizeLots(lots: TLot[]): TLotSummary { - const knownLots = lots.filter((lot) => lot.costBasis !== null) - const unknownLots = lots.filter((lot) => lot.costBasis === null) - - return { - lotCount: lots.length, - totalShares: sumShares(lots).toString(), - knownShares: sumShares(knownLots).toString(), - unknownShares: sumShares(unknownLots).toString(), - totalKnownCostBasis: sumKnownCostBasis(knownLots).toString() - } -} - -async function resolveNestedVaultAssetMetadata( - vaultMetadata: Map -): Promise> { - const assetVaultIdentifiers = getAssetVaultMetadataLookupIdentifiers(vaultMetadata) - - if (assetVaultIdentifiers.length === 0) { - return vaultMetadata - } - - const assetVaultMetadata = await fetchMultipleVaultsMetadata(assetVaultIdentifiers, { skipSnapshotFallback: true }) - return new Map([...vaultMetadata, ...assetVaultMetadata]) -} - -function serializeLots( - lots: TLot[], - shareDecimals?: number, - assetDecimals?: number -): Array<{ - index: number - shares: string - sharesFormatted?: number - costBasis: string | null - costBasisFormatted?: number | null - acquiredAt?: number -}> { - return lots.map((lot, index) => ({ - index, - shares: lot.shares.toString(), - sharesFormatted: shareDecimals === undefined ? undefined : formatAmount(lot.shares, shareDecimals), - costBasis: lot.costBasis?.toString() ?? null, - costBasisFormatted: - lot.costBasis === null || assetDecimals === undefined ? null : formatAmount(lot.costBasis, assetDecimals), - acquiredAt: lot.acquiredAt - })) -} - -function getTokenPriceForTimestamp( - tokenPriceMap: Map | null, - timestamp: number | undefined, - fallbackTokenPrice: number -): number { - if (!timestamp || timestamp <= 0) { - return fallbackTokenPrice - } - - return tokenPriceMap ? getPriceAtTimestamp(tokenPriceMap, timestamp) : fallbackTokenPrice -} - -function getKnownLotsCostBasisUsd( - lots: TLot[], - assetDecimals: number, - tokenPriceMap: Map | null, - fallbackTokenPrice: number -): number { - return lots.reduce((total, lot) => { - if (lot.costBasis === null) { - return total - } - - return ( - total + - formatAmount(lot.costBasis, assetDecimals) * - getTokenPriceForTimestamp(tokenPriceMap, lot.acquiredAt, fallbackTokenPrice) - ) - }, 0) -} - -function countTxEventsByKind(txFamilyEvents: TRawPnlEvent[]): string { - const counts = txFamilyEvents.reduce( - (totals, event) => { - if (event.kind === 'deposit') totals.deposits += 1 - if (event.kind === 'withdrawal') totals.withdrawals += 1 - if (event.kind === 'transfer') totals.transfers += 1 - return totals - }, - { deposits: 0, withdrawals: 0, transfers: 0 } - ) - - return `dep:${counts.deposits} wd:${counts.withdrawals} xfer:${counts.transfers}` -} - -function serializeJournalRows( - rows: TPnlDebugJournalRow[], - shareDecimals?: number, - assetDecimals?: number -): Array> { - return rows.map((row) => ({ - timestamp: row.timestamp, - txHash: row.txHash, - view: row.view, - direct: row.hasAddressActivity ? 'yes' : 'no', - rawEvents: row.rawEvents, - depShares: shareDecimals === undefined ? row.depositShares : formatAmount(BigInt(row.depositShares), shareDecimals), - depAssets: assetDecimals === undefined ? row.depositAssets : formatAmount(BigInt(row.depositAssets), assetDecimals), - wdShares: - shareDecimals === undefined ? row.withdrawShares : formatAmount(BigInt(row.withdrawShares), shareDecimals), - wdAssets: - assetDecimals === undefined ? row.withdrawAssets : formatAmount(BigInt(row.withdrawAssets), assetDecimals), - stake: shareDecimals === undefined ? row.stakeShares : formatAmount(BigInt(row.stakeShares), shareDecimals), - unstake: shareDecimals === undefined ? row.unstakeShares : formatAmount(BigInt(row.unstakeShares), shareDecimals), - rewardVault: - shareDecimals === undefined - ? row.rewardInVaultShares - : formatAmount(BigInt(row.rewardInVaultShares), shareDecimals), - rewardStaked: - shareDecimals === undefined - ? row.rewardInStakedShares - : formatAmount(BigInt(row.rewardInStakedShares), shareDecimals), - unkVault: - shareDecimals === undefined - ? row.unknownInVaultShares - : formatAmount(BigInt(row.unknownInVaultShares), shareDecimals), - unkStaked: - shareDecimals === undefined - ? row.unknownInStakedShares - : formatAmount(BigInt(row.unknownInStakedShares), shareDecimals), - outVault: - shareDecimals === undefined - ? row.transferOutVaultShares - : formatAmount(BigInt(row.transferOutVaultShares), shareDecimals), - outStaked: - shareDecimals === undefined - ? row.transferOutStakedShares - : formatAmount(BigInt(row.transferOutStakedShares), shareDecimals), - pnlShares: - shareDecimals === undefined - ? row.realizedKnownShares - : formatAmount(BigInt(row.realizedKnownShares), shareDecimals), - proceeds: - assetDecimals === undefined - ? row.realizedProceedsAssets - : formatAmount(BigInt(row.realizedProceedsAssets), assetDecimals), - basisConsumed: - assetDecimals === undefined - ? row.realizedBasisAssets - : formatAmount(BigInt(row.realizedBasisAssets), assetDecimals), - realizedPnl: - assetDecimals === undefined ? row.realizedPnlAssets : formatAmount(BigInt(row.realizedPnlAssets), assetDecimals) - })) -} - -type TPnlComputationArtifacts = { - userAddress: string - version: VaultVersion - unknownTransferInPnlMode: UnknownTransferInPnlMode - currentTimestamp: number - vaults: FamilyPnlLedger[] - vaultMetadata: Map - ppsData: Map> - priceData: Map> - debugTxLedgerKeys: Set -} - -function filterVaultsByAuthoritativeVersion( - vaults: FamilyPnlLedger[], - vaultMetadata: Map, - version: VaultVersion -): FamilyPnlLedger[] { - 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 formatLotSummaryForResponse( - summary: TLotSummary, - shareDecimals: number, - assetDecimals: number -): HoldingsPnLDrilldownVault['journal'][number]['vaultLotsBefore'] { - return { - ...summary, - totalSharesFormatted: formatAmount(BigInt(summary.totalShares), shareDecimals), - knownSharesFormatted: formatAmount(BigInt(summary.knownShares), shareDecimals), - unknownSharesFormatted: formatAmount(BigInt(summary.unknownShares), shareDecimals), - totalKnownCostBasisFormatted: formatAmount(BigInt(summary.totalKnownCostBasis), assetDecimals) - } -} - -function serializeLotForResponse(args: { - lot: TLot - index: number - shareDecimals: number - assetDecimals: number - resolvedPricePerShare: number - currentTokenPrice: number - ppsMap: Map | undefined - tokenPriceMap: Map | null -}): HoldingsPnLDrilldownVault['currentLots']['vault'][number] { - const { lot, index, shareDecimals, assetDecimals, resolvedPricePerShare, currentTokenPrice, ppsMap, tokenPriceMap } = - args - const acquiredAt = lot.acquiredAt ?? null - const sharesFormatted = formatAmount(lot.shares, shareDecimals) - const costBasisFormatted = lot.costBasis === null ? null : formatAmount(lot.costBasis, assetDecimals) - const pricePerShareAtAcquisition = - acquiredAt === null - ? resolvedPricePerShare - : ppsMap - ? (getPPS(ppsMap, acquiredAt) ?? resolvedPricePerShare) - : resolvedPricePerShare - const tokenPriceAtAcquisition = - acquiredAt === null ? currentTokenPrice : getTokenPriceForTimestamp(tokenPriceMap, acquiredAt, currentTokenPrice) - const currentUnderlying = sharesFormatted * resolvedPricePerShare - - return { - index, - shares: lot.shares.toString(), - sharesFormatted, - costBasis: lot.costBasis?.toString() ?? null, - costBasisFormatted, - acquiredAt, - costBasisUsd: costBasisFormatted === null ? null : costBasisFormatted * tokenPriceAtAcquisition, - pricePerShareAtAcquisition, - tokenPriceAtAcquisition, - currentUnderlying, - currentValueUsd: currentUnderlying * currentTokenPrice - } -} - -function serializeRealizedEntryForResponse(args: { - entry: FamilyPnlLedger['realizedEntries'][number] - shareDecimals: number - assetDecimals: number - currentTokenPrice: number - tokenPriceMap: Map | null - resolvedPricePerShare: number - ppsMap: Map | undefined -}): HoldingsPnLDrilldownVault['realizedEntries'][number] { - const { entry, shareDecimals, assetDecimals, currentTokenPrice, tokenPriceMap, resolvedPricePerShare, ppsMap } = args - const realizedTokenPrice = getTokenPriceForTimestamp(tokenPriceMap, entry.timestamp, currentTokenPrice) - const basisUsd = getKnownLotsCostBasisUsd(entry.consumedLots, assetDecimals, tokenPriceMap, currentTokenPrice) - - return { - timestamp: entry.timestamp, - proceedsAssets: entry.proceedsAssets.toString(), - proceedsUnderlying: formatAmount(entry.proceedsAssets, assetDecimals), - proceedsUsd: formatAmount(entry.proceedsAssets, assetDecimals) * realizedTokenPrice, - basisAssets: entry.basisAssets.toString(), - basisUnderlying: formatAmount(entry.basisAssets, assetDecimals), - basisUsd, - pnlAssets: entry.pnlAssets.toString(), - pnlUnderlying: formatAmount(entry.pnlAssets, assetDecimals), - pnlUsd: formatAmount(entry.proceedsAssets, assetDecimals) * realizedTokenPrice - basisUsd, - consumedLots: entry.consumedLots.map((lot, index) => - serializeLotForResponse({ - lot, - index, - shareDecimals, - assetDecimals, - resolvedPricePerShare, - currentTokenPrice, - ppsMap, - tokenPriceMap - }) - ) - } -} - -function serializeUnknownTransferInEntryForResponse(args: { - entry: FamilyPnlLedger['unknownTransferInEntries'][number] - shareDecimals: number - currentTokenPrice: number - resolvedPricePerShare: number - ppsMap: Map | undefined - tokenPriceMap: Map | null -}): HoldingsPnLDrilldownVault['unknownTransferInEntries'][number] { - const { entry, shareDecimals, currentTokenPrice, resolvedPricePerShare, ppsMap, tokenPriceMap } = args - const pricePerShareAtReceipt = ppsMap - ? (getPPS(ppsMap, entry.timestamp) ?? resolvedPricePerShare) - : resolvedPricePerShare - const tokenPriceAtReceipt = getTokenPriceForTimestamp(tokenPriceMap, entry.timestamp, currentTokenPrice) - const sharesFormatted = formatAmount(entry.shares, shareDecimals) - const receiptUnderlying = sharesFormatted * pricePerShareAtReceipt - - return { - timestamp: entry.timestamp, - location: entry.location, - shares: entry.shares.toString(), - sharesFormatted, - pricePerShareAtReceipt, - tokenPriceAtReceipt, - receiptUnderlying, - receiptValueUsd: receiptUnderlying * tokenPriceAtReceipt - } -} - -function serializeRewardTransferInEntryForResponse(args: { - entry: FamilyPnlLedger['rewardTransferInEntries'][number] - shareDecimals: number - currentTokenPrice: number - resolvedPricePerShare: number - ppsMap: Map | undefined - tokenPriceMap: Map | null -}): HoldingsPnLDrilldownVault['rewardTransferInEntries'][number] { - const { entry, shareDecimals, currentTokenPrice, resolvedPricePerShare, ppsMap, tokenPriceMap } = args - const pricePerShareAtReceipt = ppsMap - ? (getPPS(ppsMap, entry.timestamp) ?? resolvedPricePerShare) - : resolvedPricePerShare - const tokenPriceAtReceipt = getTokenPriceForTimestamp(tokenPriceMap, entry.timestamp, currentTokenPrice) - const sharesFormatted = formatAmount(entry.shares, shareDecimals) - const receiptUnderlying = sharesFormatted * pricePerShareAtReceipt - - return { - timestamp: entry.timestamp, - location: entry.location, - distributor: entry.distributor, - shares: entry.shares.toString(), - sharesFormatted, - pricePerShareAtReceipt, - tokenPriceAtReceipt, - receiptUnderlying, - receiptValueUsd: receiptUnderlying * tokenPriceAtReceipt - } -} - -function serializeUnknownWithdrawalEntryForResponse(args: { - entry: FamilyPnlLedger['unknownWithdrawalEntries'][number] - shareDecimals: number - assetDecimals: number - currentTokenPrice: number - tokenPriceMap: Map | null - resolvedPricePerShare: number - ppsMap: Map | undefined -}): HoldingsPnLDrilldownVault['unknownWithdrawalEntries'][number] { - const { entry, shareDecimals, assetDecimals, currentTokenPrice, tokenPriceMap, resolvedPricePerShare, ppsMap } = args - const realizedTokenPrice = getTokenPriceForTimestamp(tokenPriceMap, entry.timestamp, currentTokenPrice) - - return { - timestamp: entry.timestamp, - shares: entry.shares.toString(), - sharesFormatted: formatAmount(entry.shares, shareDecimals), - proceedsAssets: entry.proceedsAssets.toString(), - proceedsUnderlying: formatAmount(entry.proceedsAssets, assetDecimals), - proceedsUsd: formatAmount(entry.proceedsAssets, assetDecimals) * realizedTokenPrice, - consumedLots: entry.consumedLots.map((lot, index) => - serializeLotForResponse({ - lot, - index, - shareDecimals, - assetDecimals, - resolvedPricePerShare, - currentTokenPrice, - ppsMap, - tokenPriceMap - }) - ) - } -} - -function serializeJournalEntryForResponse(args: { - row: TPnlDebugJournalRow - shareDecimals: number - assetDecimals: number -}): HoldingsPnLDrilldownVault['journal'][number] { - const { row, shareDecimals, assetDecimals } = args - - return { - timestamp: row.timestamp, - txHash: row.txHash, - view: row.view, - hasAddressActivity: row.hasAddressActivity, - rawEvents: row.rawEvents, - depositShares: row.depositShares, - depositSharesFormatted: formatAmount(BigInt(row.depositShares), shareDecimals), - depositAssets: row.depositAssets, - depositAssetsFormatted: formatAmount(BigInt(row.depositAssets), assetDecimals), - withdrawShares: row.withdrawShares, - withdrawSharesFormatted: formatAmount(BigInt(row.withdrawShares), shareDecimals), - withdrawAssets: row.withdrawAssets, - withdrawAssetsFormatted: formatAmount(BigInt(row.withdrawAssets), assetDecimals), - stakeShares: row.stakeShares, - stakeSharesFormatted: formatAmount(BigInt(row.stakeShares), shareDecimals), - unstakeShares: row.unstakeShares, - unstakeSharesFormatted: formatAmount(BigInt(row.unstakeShares), shareDecimals), - rewardInVaultShares: row.rewardInVaultShares, - rewardInVaultSharesFormatted: formatAmount(BigInt(row.rewardInVaultShares), shareDecimals), - rewardInStakedShares: row.rewardInStakedShares, - rewardInStakedSharesFormatted: formatAmount(BigInt(row.rewardInStakedShares), shareDecimals), - unknownInVaultShares: row.unknownInVaultShares, - unknownInVaultSharesFormatted: formatAmount(BigInt(row.unknownInVaultShares), shareDecimals), - unknownInStakedShares: row.unknownInStakedShares, - unknownInStakedSharesFormatted: formatAmount(BigInt(row.unknownInStakedShares), shareDecimals), - transferOutVaultShares: row.transferOutVaultShares, - transferOutVaultSharesFormatted: formatAmount(BigInt(row.transferOutVaultShares), shareDecimals), - transferOutStakedShares: row.transferOutStakedShares, - transferOutStakedSharesFormatted: formatAmount(BigInt(row.transferOutStakedShares), shareDecimals), - realizedKnownShares: row.realizedKnownShares, - realizedKnownSharesFormatted: formatAmount(BigInt(row.realizedKnownShares), shareDecimals), - realizedProceedsAssets: row.realizedProceedsAssets, - realizedProceedsAssetsFormatted: formatAmount(BigInt(row.realizedProceedsAssets), assetDecimals), - realizedBasisAssets: row.realizedBasisAssets, - realizedBasisAssetsFormatted: formatAmount(BigInt(row.realizedBasisAssets), assetDecimals), - realizedPnlAssets: row.realizedPnlAssets, - realizedPnlAssetsFormatted: formatAmount(BigInt(row.realizedPnlAssets), assetDecimals), - vaultLotsBefore: formatLotSummaryForResponse(row.vaultLotsBefore, shareDecimals, assetDecimals), - stakedLotsBefore: formatLotSummaryForResponse(row.stakedLotsBefore, shareDecimals, assetDecimals), - vaultLotsAfter: formatLotSummaryForResponse(row.vaultLotsAfter, shareDecimals, assetDecimals), - stakedLotsAfter: formatLotSummaryForResponse(row.stakedLotsAfter, shareDecimals, assetDecimals) - } -} - -function serializeRawEvent(event: TRawPnlEvent): Record { - if (event.kind === 'deposit') { - return { - kind: event.kind, - vaultAddress: event.vaultAddress, - familyVaultAddress: event.familyVaultAddress, - isStakingVault: event.isStakingVault, - transactionHash: event.transactionHash, - logIndex: event.logIndex, - shares: event.shares.toString(), - assets: event.assets.toString(), - owner: event.owner, - sender: event.sender, - scopes: event.scopes - } - } - - if (event.kind === 'withdrawal') { - return { - kind: event.kind, - vaultAddress: event.vaultAddress, - familyVaultAddress: event.familyVaultAddress, - isStakingVault: event.isStakingVault, - transactionHash: event.transactionHash, - logIndex: event.logIndex, - shares: event.shares.toString(), - assets: event.assets.toString(), - owner: event.owner, - scopes: event.scopes - } - } - - return { - kind: event.kind, - vaultAddress: event.vaultAddress, - familyVaultAddress: event.familyVaultAddress, - isStakingVault: event.isStakingVault, - transactionHash: event.transactionHash, - logIndex: event.logIndex, - shares: event.shares.toString(), - sender: event.sender, - receiver: event.receiver, - scopes: event.scopes - } -} - -function matchesLedgerDebugVault(ledger: FamilyPnlLedger, vaultFilter: string | null): boolean { - if (vaultFilter === null) { - return true - } - - return ledger.vaultAddress === vaultFilter || ledger.stakingVaultAddress === vaultFilter -} - -function shouldLogLotTransactions(ledger: FamilyPnlLedger, transactionHash: string): boolean { - const { lotsEnabled, vaultFilter, txFilter } = getHoldingsDebugFilters() - - if (!lotsEnabled || (vaultFilter === null && txFilter === null)) { - return false - } - - if (!matchesLedgerDebugVault(ledger, vaultFilter)) { - return false - } - - if (txFilter !== null && transactionHash.toLowerCase() !== txFilter) { - return false - } - - return true -} - -function shouldIncludeDetailedLotLogs(): boolean { - const { lotsEnabled, vaultFilter, txFilter } = getHoldingsDebugFilters() - return lotsEnabled && (vaultFilter !== null || txFilter !== null) -} - -function getDebugTxLedgerKeys(events: TRawPnlEvent[]): Set { - const { lotsEnabled, txFilter } = getHoldingsDebugFilters() - - if (!lotsEnabled || txFilter === null) { - return new Set() - } - - return events.reduce>((keys, event) => { - if (event.transactionHash.toLowerCase() === txFilter) { - keys.add(toVaultKey(event.chainId, event.familyVaultAddress)) - } - return keys - }, new Set()) -} - -function shouldLogFinalLots(ledger: FamilyPnlLedger, debugTxLedgerKeys: Set): boolean { - const { lotsEnabled, vaultFilter, txFilter } = getHoldingsDebugFilters() - - if (!lotsEnabled) { - return false - } - - if (!matchesLedgerDebugVault(ledger, vaultFilter)) { - return false - } - - if (txFilter !== null) { - return debugTxLedgerKeys.has(toVaultKey(ledger.chainId, ledger.vaultAddress)) - } - - return true -} - -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' - } - }) -} - -export function buildRawPnlEvents(context: RawPnlEventContext): TRawPnlEvent[] { - const merged = new Map() - const eventSources: Array<{ events: Array>; scope: keyof TRawScopes }> = [ - { events: context.addressEvents.deposits.map(normalizeDeposit), scope: 'address' }, - { events: context.addressEvents.withdrawals.map(normalizeWithdrawal), scope: 'address' }, - { events: context.addressEvents.transfersIn.map(normalizeTransfer), scope: 'address' }, - { events: context.addressEvents.transfersOut.map(normalizeTransfer), scope: 'address' }, - { events: context.transactionEvents.deposits.map(normalizeDeposit), scope: 'tx' }, - { events: context.transactionEvents.withdrawals.map(normalizeWithdrawal), scope: 'tx' }, - { events: context.transactionEvents.transfers.map(normalizeTransfer), scope: 'tx' } - ] - - eventSources.forEach(({ events, scope }) => { - events.forEach((event) => { - mergeRawEvent(merged, event, scope) - }) - }) - - return Array.from(merged.values()).sort(compareRawEvents) -} - -function groupEventsByTransaction(events: TRawPnlEvent[]): TRawPnlEvent[][] { - const grouped = events.reduce>((groups, event) => { - const transactionKey = `${event.chainId}:${event.transactionHash}` - const bucket = groups.get(transactionKey) ?? [] - bucket.push(event) - groups.set(transactionKey, bucket) - return groups - }, new Map()) - - return Array.from(grouped.values()).map((txEvents) => [...txEvents].sort(compareRawEvents)) -} - -function groupTransactionEventsByFamily(txEvents: TRawPnlEvent[]): TRawPnlEvent[][] { - const grouped = txEvents.reduce>((groups, event) => { - const familyKey = toVaultKey(event.chainId, event.familyVaultAddress) - const bucket = groups.get(familyKey) ?? [] - bucket.push(event) - groups.set(familyKey, bucket) - return groups - }, new Map()) - - return Array.from(grouped.values()).map((familyEvents) => [...familyEvents].sort(compareRawEvents)) -} - -function createFamilyLedger(chainId: number, familyVaultAddress: string): FamilyPnlLedger { - return { - chainId, - vaultAddress: familyVaultAddress, - stakingVaultAddress: getStakingVaultAddress(chainId, familyVaultAddress), - vaultLots: [], - stakedLots: [], - totalDepositedAssets: ZERO, - totalWithdrawnAssets: ZERO, - unknownCostBasisTransferInCount: 0, - unknownCostBasisTransferInShares: ZERO, - withdrawalsWithUnknownCostBasis: 0, - unmatchedTransferOutCount: 0, - unmatchedTransferOutShares: ZERO, - realizedEntries: [], - rewardTransferInEntries: [], - unknownTransferInEntries: [], - unknownWithdrawalEntries: [], - debugJournal: [], - eventCounts: { - underlyingDeposits: 0, - underlyingWithdrawals: 0, - stakes: 0, - unstakes: 0, - rewardTransfersIn: 0, - externalTransfersIn: 0, - externalTransfersOut: 0, - migrationsIn: 0, - migrationsOut: 0 - } - } -} - -function getOrCreateLedger( - ledgers: Map, - chainId: number, - familyVaultAddress: string -): FamilyPnlLedger { - const vaultKey = toVaultKey(chainId, familyVaultAddress) - const existing = ledgers.get(vaultKey) - - if (existing) { - return existing - } - - const ledger = createFamilyLedger(chainId, familyVaultAddress) - ledgers.set(vaultKey, ledger) - return ledger -} - -function getLocationLots(ledger: FamilyPnlLedger, location: TLocation): TLot[] { - return location === 'vault' ? ledger.vaultLots : ledger.stakedLots -} - -function setLocationLots(ledger: FamilyPnlLedger, location: TLocation, lots: TLot[]): void { - if (location === 'vault') { - ledger.vaultLots = lots - return - } - - ledger.stakedLots = lots -} - -function addLotsToLocation(ledger: FamilyPnlLedger, location: TLocation, lots: TLot[]): void { - const existingLots = getLocationLots(ledger, location) - const nextLots = [...existingLots] - - lots - .filter((lot) => lot.shares > ZERO) - .forEach((lot) => { - nextLots.push(lot) - }) - - setLocationLots(ledger, location, nextLots) -} - -function consumeLots( - lots: TLot[], - targetShares: bigint -): { nextLots: TLot[]; consumedLots: TLot[]; consumedShares: bigint } { - const remaining = { value: targetShares } - const nextLots: TLot[] = [] - const consumedLots: TLot[] = [] - - lots.forEach((lot) => { - if (lot.shares === ZERO) { - return - } - - if (remaining.value === ZERO) { - nextLots.push(lot) - return - } - - const consumedShares = minBigInt(lot.shares, remaining.value) - const remainingShares = lot.shares - consumedShares - const consumedCostBasis = lot.costBasis === null ? null : (lot.costBasis * consumedShares) / lot.shares - const remainingCostBasis = lot.costBasis === null ? null : lot.costBasis - (consumedCostBasis ?? ZERO) - - consumedLots.push(createLot(consumedShares, consumedCostBasis, lot.acquiredAt)) - - if (remainingShares > ZERO) { - nextLots.push(createLot(remainingShares, remainingCostBasis, lot.acquiredAt)) - } - - remaining.value -= consumedShares - }) - - return { - nextLots, - consumedLots: consumedLots.filter((lot) => lot.shares > ZERO), - consumedShares: targetShares - remaining.value - } -} - -function consumeFromLocation( - ledger: FamilyPnlLedger, - location: TLocation, - targetShares: bigint -): { consumedLots: TLot[]; consumedShares: bigint } { - const consumed = consumeLots(getLocationLots(ledger, location), targetShares) - setLocationLots(ledger, location, consumed.nextLots) - return { - consumedLots: consumed.consumedLots, - consumedShares: consumed.consumedShares - } -} - -function moveBetweenLocations(ledger: FamilyPnlLedger, from: TLocation, to: TLocation, targetShares: bigint): bigint { - const consumed = consumeFromLocation(ledger, from, targetShares) - addLotsToLocation(ledger, to, consumed.consumedLots) - return consumed.consumedShares -} - -function calculateUserContractDelta(txEvents: TRawPnlEvent[], userAddress: string, contractAddress: string): bigint { - return txEvents.reduce((delta, event) => { - if (!event.scopes.address || event.vaultAddress !== contractAddress) { - return delta - } - - if (event.kind === 'deposit' && event.owner === userAddress) { - return delta + event.shares - } - - if (event.kind === 'withdrawal' && event.owner === userAddress) { - return delta - event.shares - } - - if (event.kind === 'transfer' && event.receiver === userAddress && event.sender !== ZERO_ADDRESS) { - return delta + event.shares - } - - if (event.kind === 'transfer' && event.sender === userAddress && event.receiver !== ZERO_ADDRESS) { - return delta - event.shares - } - - return delta - }, ZERO) -} - -function sumEventShares(events: T[]): bigint { - return events.reduce((total, event) => total + event.shares, ZERO) -} - -function sumEventAssets(events: T[]): bigint { - return events.reduce((total, event) => total + event.assets, ZERO) -} - -function hasAddressScopedFamilyActivity( - txFamilyEvents: TRawPnlEvent[], - familyVaultAddress: string, - stakingVaultAddress: string | null -): boolean { - return txFamilyEvents.some( - (event) => - event.scopes.address && - (event.vaultAddress === familyVaultAddress || - (stakingVaultAddress !== null && event.vaultAddress === stakingVaultAddress)) - ) -} - -function shouldIncludeFamilyVaultAssetEvent( - event: Extract, - familyHasAddressActivity: boolean -): boolean { - return event.scopes.address || familyHasAddressActivity -} - -function isLedgerEmpty(ledger: FamilyPnlLedger): boolean { - return ( - sumShares(ledger.vaultLots) === ZERO && - sumShares(ledger.stakedLots) === ZERO && - ledger.totalDepositedAssets === ZERO && - ledger.totalWithdrawnAssets === ZERO && - ledger.unknownCostBasisTransferInCount === 0 && - ledger.unmatchedTransferOutCount === 0 && - ledger.realizedEntries.length === 0 - ) -} - -function isDirectInteractionLedger(ledger: FamilyPnlLedger): boolean { - return ( - ledger.totalDepositedAssets > ZERO || - ledger.totalWithdrawnAssets > ZERO || - ledger.realizedEntries.length > 0 || - ledger.eventCounts.migrationsIn > 0 - ) -} - -function hasCurrentShares(ledger: FamilyPnlLedger): boolean { - return sumShares(ledger.vaultLots) > ZERO || sumShares(ledger.stakedLots) > ZERO -} - -function isCurrentTransferOnlyHoldingsLedger(ledger: FamilyPnlLedger): boolean { - return ( - hasCurrentShares(ledger) && - !isDirectInteractionLedger(ledger) && - ledger.realizedEntries.length === 0 && - ledger.unknownWithdrawalEntries.length === 0 && - [...ledger.vaultLots, ...ledger.stakedLots].every((lot) => lot.costBasis === null) - ) -} - -type TDetectedKnownMigration = { - chainId: number - sourceFamilyVaultAddress: string - destinationFamilyVaultAddress: string - transactionHash: string - blockTimestamp: number - sourceTransferShares: bigint - destinationDepositShares: bigint - destinationDepositAssets: bigint - sourceUnderlyingWithdrawCount: number - sourceUnderlyingWithdrawAssets: bigint - destinationUnderlyingDepositCount: number - destinationUnderlyingDepositAssets: bigint -} - -export function filterDirectInteractionLedgers(ledgers: Map): Map { - return Array.from(ledgers.entries()).reduce>((filtered, [key, ledger]) => { - if (isDirectInteractionLedger(ledger)) { - filtered.set(key, ledger) - } - - return filtered - }, new Map()) -} - -export function filterRelevantHoldingsLedgers(ledgers: Map): Map { - return Array.from(ledgers.entries()).reduce>((filtered, [key, ledger]) => { - if (isDirectInteractionLedger(ledger) || hasCurrentShares(ledger)) { - filtered.set(key, ledger) - } - - return filtered - }, new Map()) -} - -function applyRealization( - ledger: FamilyPnlLedger, - totalShares: bigint, - totalAssets: bigint, - stakingShares: bigint, - vaultShares: bigint, - timestamp: number -): { - realizedPnlAssets: bigint - knownShares: bigint - unknownShares: bigint - knownProceedsAssets: bigint - knownCostBasisAssets: bigint - consumedVaultShares: bigint - consumedStakedShares: bigint -} { - const consumedStaked = consumeFromLocation(ledger, 'staked', stakingShares) - const consumedVault = consumeFromLocation(ledger, 'vault', vaultShares) - const consumedLots = [...consumedStaked.consumedLots, ...consumedVault.consumedLots] - const knownLots = consumedLots.filter((lot) => lot.costBasis !== null) - const unknownLots = consumedLots.filter((lot) => lot.costBasis === null) - const knownShares = sumShares(knownLots) - const knownCostBasis = sumKnownCostBasis(knownLots) - const knownProceeds = totalShares === ZERO ? ZERO : (totalAssets * knownShares) / totalShares - const unknownShares = consumedStaked.consumedShares + consumedVault.consumedShares - knownShares - const unknownProceeds = totalShares === ZERO ? ZERO : (totalAssets * unknownShares) / totalShares - - if (knownShares > ZERO) { - ledger.realizedEntries.push({ - timestamp, - proceedsAssets: knownProceeds, - basisAssets: knownCostBasis, - pnlAssets: knownProceeds - knownCostBasis, - consumedLots: knownLots - }) - } - - if (unknownShares > ZERO) { - ledger.withdrawalsWithUnknownCostBasis += 1 - ledger.unknownWithdrawalEntries.push({ - timestamp, - shares: unknownShares, - proceedsAssets: unknownProceeds, - consumedLots: unknownLots - }) - } - - if (consumedStaked.consumedShares + consumedVault.consumedShares < totalShares) { - ledger.unmatchedTransferOutCount += 1 - ledger.unmatchedTransferOutShares += totalShares - (consumedStaked.consumedShares + consumedVault.consumedShares) - } - - return { - realizedPnlAssets: knownShares > ZERO ? knownProceeds - knownCostBasis : ZERO, - knownShares, - unknownShares, - knownProceedsAssets: knownProceeds, - knownCostBasisAssets: knownCostBasis, - consumedVaultShares: consumedVault.consumedShares, - consumedStakedShares: consumedStaked.consumedShares - } -} - -function buildJournalView(parts: { - acquiredToVault: bigint - acquiredToStaked: bigint - vaultRealizeShares: bigint - stakingRealizeShares: bigint - stakeShares: bigint - unstakeShares: bigint - rewardInVaultShares: bigint - rewardInStakedShares: bigint - unknownInVaultShares: bigint - unknownInStakedShares: bigint - transferOutVaultShares: bigint - transferOutStakedShares: bigint -}): string { - const labels: string[] = [] - - if (parts.acquiredToVault > ZERO) labels.push('deposit->vault') - if (parts.acquiredToStaked > ZERO) labels.push('deposit->staked') - if (parts.vaultRealizeShares > ZERO) labels.push('withdraw<-vault') - if (parts.stakingRealizeShares > ZERO) labels.push('withdraw<-staked') - if (parts.stakeShares > ZERO) labels.push('stake') - if (parts.unstakeShares > ZERO) labels.push('unstake') - if (parts.rewardInVaultShares > ZERO) labels.push('reward_in_vault') - if (parts.rewardInStakedShares > ZERO) labels.push('reward_in_staked') - if (parts.unknownInVaultShares > ZERO) labels.push('transfer_in_vault_unknown') - if (parts.unknownInStakedShares > ZERO) labels.push('transfer_in_staked_unknown') - if (parts.transferOutVaultShares > ZERO) labels.push('transfer_out_vault') - if (parts.transferOutStakedShares > ZERO) labels.push('transfer_out_staked') - - return labels.length > 0 ? labels.join(' + ') : 'no_position_change' -} - -function addAcquisitionLots( - ledger: FamilyPnlLedger, - totalShares: bigint, - totalAssets: bigint, - stakedShares: bigint, - vaultShares: bigint, - timestamp: number -): void { - if (totalShares === ZERO) { - return - } - - const stakedAssets = (totalAssets * stakedShares) / totalShares - const vaultAssets = (totalAssets * vaultShares) / totalShares - - addLotsToLocation(ledger, 'staked', [createLot(stakedShares, stakedAssets, timestamp)]) - addLotsToLocation(ledger, 'vault', [createLot(vaultShares, vaultAssets, timestamp)]) -} - -function redistributeLotsToTargetShares(lots: TLot[], targetShares: bigint): TLot[] { - const totalShares = sumShares(lots) - - if (totalShares === ZERO || targetShares === ZERO) { - return [] - } - - let allocatedShares = ZERO - - return lots.reduce((redistributed, lot, index) => { - const shares = - index === lots.length - 1 ? targetShares - allocatedShares : (targetShares * lot.shares) / totalShares - - allocatedShares += shares - - if (shares > ZERO) { - redistributed.push(createLot(shares, lot.costBasis, lot.acquiredAt)) - } - - return redistributed - }, []) -} - -function handleUnknownTransferIn( - ledger: FamilyPnlLedger, - location: TLocation, - shares: bigint, - timestamp: number -): void { - if (shares === ZERO) { - return - } - - ledger.eventCounts.externalTransfersIn += 1 - ledger.unknownCostBasisTransferInCount += 1 - ledger.unknownCostBasisTransferInShares += shares - ledger.unknownTransferInEntries.push({ - timestamp, - shares, - location - }) - addLotsToLocation(ledger, location, [createLot(shares, null, timestamp)]) -} - -function handleRewardTransferIn( - ledger: FamilyPnlLedger, - location: TLocation, - shares: bigint, - timestamp: number, - distributor: string -): void { - if (shares === ZERO) { - return - } - - ledger.eventCounts.rewardTransfersIn += 1 - ledger.rewardTransferInEntries.push({ - timestamp, - shares, - location, - distributor - }) - addLotsToLocation(ledger, location, [createLot(shares, ZERO, timestamp)]) -} - -function getRecognizedRewardTransferIns( - txFamilyEvents: TRawPnlEvent[], - userAddress: string, - familyVaultAddress: string, - stakingVaultAddress: string | null -): FamilyPnlLedger['rewardTransferInEntries'] { - return txFamilyEvents.flatMap((event) => { - if ( - event.kind !== 'transfer' || - !event.scopes.address || - event.receiver !== userAddress || - !isKnownZeroBasisRewardDistribution(event.chainId, event.sender, event.vaultAddress) - ) { - return [] - } - - if (event.vaultAddress === familyVaultAddress) { - return [ - { - timestamp: event.blockTimestamp, - shares: event.shares, - location: 'vault' as const, - distributor: event.sender - } - ] - } - - if (stakingVaultAddress !== null && event.vaultAddress === stakingVaultAddress) { - return [ - { - timestamp: event.blockTimestamp, - shares: event.shares, - location: 'staked' as const, - distributor: event.sender - } - ] - } - - return [] - }) -} - -function allocateRecognizedRewardTransferIns( - rewardEntries: FamilyPnlLedger['rewardTransferInEntries'], - availableVaultShares: bigint, - availableStakedShares: bigint -): { - allocatedEntries: FamilyPnlLedger['rewardTransferInEntries'] - rewardInVaultShares: bigint - rewardInStakedShares: bigint -} { - let remainingVaultShares = availableVaultShares - let remainingStakedShares = availableStakedShares - const allocatedEntries: FamilyPnlLedger['rewardTransferInEntries'] = [] - - rewardEntries.forEach((entry) => { - const availableShares = entry.location === 'vault' ? remainingVaultShares : remainingStakedShares - const allocatedShares = minBigInt(entry.shares, availableShares) - - if (allocatedShares === ZERO) { - return - } - - allocatedEntries.push({ - ...entry, - shares: allocatedShares - }) - - if (entry.location === 'vault') { - remainingVaultShares -= allocatedShares - return - } - - remainingStakedShares -= allocatedShares - }) - - return { - allocatedEntries, - rewardInVaultShares: availableVaultShares - remainingVaultShares, - rewardInStakedShares: availableStakedShares - remainingStakedShares - } -} - -function handleExternalTransferOut(ledger: FamilyPnlLedger, location: TLocation, shares: bigint): void { - if (shares === ZERO) { - return - } - - ledger.eventCounts.externalTransfersOut += 1 - - const consumed = consumeFromLocation(ledger, location, shares) - - if (consumed.consumedShares < shares) { - ledger.unmatchedTransferOutCount += 1 - ledger.unmatchedTransferOutShares += shares - consumed.consumedShares - } -} - -function detectKnownMigratorCrossFamilyRollover( - txEvents: TRawPnlEvent[], - userAddress: string -): TDetectedKnownMigration | null { - const txFamilyGroups = groupTransactionEventsByFamily(txEvents) - const sourceFamilyCandidates = txFamilyGroups.filter((familyEvents) => { - const addressTransfersToMigrator = familyEvents.filter( - (event): event is Extract => - event.kind === 'transfer' && - event.scopes.address && - event.sender === userAddress && - KNOWN_VAULT_ROLLOVER_INTERMEDIARIES.has(event.receiver) - ) - - if (addressTransfersToMigrator.length === 0) { - return false - } - - if ( - familyEvents.some((event) => event.scopes.address && (event.kind === 'deposit' || event.kind === 'withdrawal')) - ) { - return false - } - - const migratorAddress = addressTransfersToMigrator[0]?.receiver ?? null - - if (migratorAddress === null || addressTransfersToMigrator.some((event) => event.receiver !== migratorAddress)) { - return false - } - - const burnedShares = sumEventShares( - familyEvents.filter( - (event): event is Extract => - event.kind === 'transfer' && event.sender === migratorAddress && event.receiver === ZERO_ADDRESS - ) - ) - - return burnedShares > ZERO && burnedShares === sumEventShares(addressTransfersToMigrator) - }) - const destinationFamilyCandidates = txFamilyGroups.filter((familyEvents) => { - const addressDeposits = familyEvents.filter( - (event): event is Extract => - event.kind === 'deposit' && event.scopes.address && event.owner === userAddress - ) - - if (addressDeposits.length === 0) { - return false - } - - const mintedShares = sumEventShares( - familyEvents.filter( - (event): event is Extract => - event.kind === 'transfer' && - event.scopes.address && - event.sender === ZERO_ADDRESS && - event.receiver === userAddress - ) - ) - - return mintedShares > ZERO - }) - const sourceFamilyEvents = sourceFamilyCandidates[0] - const destinationFamilyEvents = destinationFamilyCandidates[0] - - if ( - sourceFamilyCandidates.length !== 1 || - destinationFamilyCandidates.length !== 1 || - !sourceFamilyEvents || - !destinationFamilyEvents - ) { - return null - } - - const sourceFamilyVaultAddress = sourceFamilyEvents[0]?.familyVaultAddress - const destinationFamilyVaultAddress = destinationFamilyEvents[0]?.familyVaultAddress - const chainId = sourceFamilyEvents[0]?.chainId - - if ( - !sourceFamilyVaultAddress || - !destinationFamilyVaultAddress || - chainId === undefined || - destinationFamilyEvents[0]?.chainId !== chainId || - sourceFamilyVaultAddress === destinationFamilyVaultAddress - ) { - return null - } - - const sourceTransfersToMigrator = sourceFamilyEvents.filter( - (event): event is Extract => - event.kind === 'transfer' && - event.scopes.address && - event.sender === userAddress && - KNOWN_VAULT_ROLLOVER_INTERMEDIARIES.has(event.receiver) - ) - const destinationDeposits = destinationFamilyEvents.filter( - (event): event is Extract => - event.kind === 'deposit' && event.scopes.address && event.owner === userAddress - ) - const destinationMintsToUser = destinationFamilyEvents.filter( - (event): event is Extract => - event.kind === 'transfer' && - event.scopes.address && - event.sender === ZERO_ADDRESS && - event.receiver === userAddress - ) - const sourceTransferShares = sumEventShares(sourceTransfersToMigrator) - const destinationMintedShares = sumEventShares(destinationMintsToUser) - const destinationDepositShares = - destinationMintedShares > ZERO ? destinationMintedShares : sumEventShares(destinationDeposits) - const destinationDepositAssets = sumEventAssets(destinationDeposits) - - if (sourceTransferShares === ZERO || destinationDepositShares === ZERO || destinationDepositAssets === ZERO) { - return null - } - - return { - chainId, - sourceFamilyVaultAddress, - destinationFamilyVaultAddress, - transactionHash: sourceFamilyEvents[0]?.transactionHash ?? destinationFamilyEvents[0]?.transactionHash ?? '', - blockTimestamp: sourceFamilyEvents[0]?.blockTimestamp ?? destinationFamilyEvents[0]?.blockTimestamp ?? 0, - sourceTransferShares, - destinationDepositShares, - destinationDepositAssets, - sourceUnderlyingWithdrawCount: 0, - sourceUnderlyingWithdrawAssets: ZERO, - destinationUnderlyingDepositCount: 0, - destinationUnderlyingDepositAssets: ZERO - } -} - -function detectKnownCompatibleAssetVaultCrossFamilyRollover( - txEvents: TRawPnlEvent[], - userAddress: string, - vaultMetadata: Map -): TDetectedKnownMigration | null { - const txFamilyGroups = groupTransactionEventsByFamily(txEvents) - const candidates = txFamilyGroups.flatMap((outerFamilyEvents) => { - const outerFamilyVaultAddress = outerFamilyEvents[0]?.familyVaultAddress - const chainId = outerFamilyEvents[0]?.chainId - - if (!outerFamilyVaultAddress || chainId === undefined) { - return [] - } - - const outerMetadata = vaultMetadata.get(toVaultKey(chainId, outerFamilyVaultAddress)) - - if (!outerMetadata) { - return [] - } - - const innerFamilyVaultAddress = outerMetadata.token.address.toLowerCase() - - if (!isKnownCompatibleAssetVaultRollover(chainId, outerFamilyVaultAddress, innerFamilyVaultAddress)) { - return [] - } - - const innerFamilyEvents = txFamilyGroups.find( - (familyEvents) => - familyEvents[0]?.chainId === chainId && familyEvents[0]?.familyVaultAddress === innerFamilyVaultAddress - ) - - if (!innerFamilyEvents) { - return [] - } - - const destinationDeposits = outerFamilyEvents.filter( - (event): event is Extract => - event.kind === 'deposit' && event.scopes.address && event.owner === userAddress - ) - const destinationMintsToUser = outerFamilyEvents.filter( - (event): event is Extract => - event.kind === 'transfer' && - event.scopes.address && - event.sender === ZERO_ADDRESS && - event.receiver === userAddress - ) - const sourceTransfersToOuterVault = innerFamilyEvents.filter( - (event): event is Extract => - event.kind === 'transfer' && - event.scopes.address && - event.sender === userAddress && - event.receiver === outerFamilyVaultAddress - ) - const sourceTransferShares = sumEventShares(sourceTransfersToOuterVault) - const destinationDepositAssets = sumEventAssets(destinationDeposits) - const destinationMintedShares = sumEventShares(destinationMintsToUser) - const destinationDepositShares = - destinationMintedShares > ZERO ? destinationMintedShares : sumEventShares(destinationDeposits) - - if ( - sourceTransferShares > ZERO && - destinationDepositShares > ZERO && - destinationDepositAssets > ZERO && - destinationDepositAssets === sourceTransferShares - ) { - return [ - { - chainId, - sourceFamilyVaultAddress: innerFamilyVaultAddress, - destinationFamilyVaultAddress: outerFamilyVaultAddress, - transactionHash: innerFamilyEvents[0]?.transactionHash ?? outerFamilyEvents[0]?.transactionHash ?? '', - blockTimestamp: innerFamilyEvents[0]?.blockTimestamp ?? outerFamilyEvents[0]?.blockTimestamp ?? 0, - sourceTransferShares, - destinationDepositShares, - destinationDepositAssets, - sourceUnderlyingWithdrawCount: 0, - sourceUnderlyingWithdrawAssets: ZERO, - destinationUnderlyingDepositCount: destinationDeposits.length, - destinationUnderlyingDepositAssets: destinationDepositAssets - } - ] - } - - const sourceWithdrawals = outerFamilyEvents.filter( - (event): event is Extract => - event.kind === 'withdrawal' && event.scopes.address && event.owner === userAddress - ) - const sourceBurnedShares = sumEventShares( - outerFamilyEvents.filter( - (event): event is Extract => - event.kind === 'transfer' && - event.scopes.address && - event.sender === userAddress && - event.receiver === ZERO_ADDRESS - ) - ) - const destinationTransfersFromOuterVault = innerFamilyEvents.filter( - (event): event is Extract => - event.kind === 'transfer' && - event.scopes.address && - event.sender === outerFamilyVaultAddress && - event.receiver === userAddress - ) - const sourceWithdrawShares = sumEventShares(sourceWithdrawals) - const sourceWithdrawAssets = sumEventAssets(sourceWithdrawals) - const destinationTransferShares = sumEventShares(destinationTransfersFromOuterVault) - - if ( - sourceWithdrawShares > ZERO && - sourceWithdrawAssets > ZERO && - destinationTransferShares > ZERO && - sourceWithdrawAssets === destinationTransferShares && - (sourceBurnedShares === ZERO || sourceBurnedShares === sourceWithdrawShares) - ) { - return [ - { - chainId, - sourceFamilyVaultAddress: outerFamilyVaultAddress, - destinationFamilyVaultAddress: innerFamilyVaultAddress, - transactionHash: outerFamilyEvents[0]?.transactionHash ?? innerFamilyEvents[0]?.transactionHash ?? '', - blockTimestamp: outerFamilyEvents[0]?.blockTimestamp ?? innerFamilyEvents[0]?.blockTimestamp ?? 0, - sourceTransferShares: sourceWithdrawShares, - destinationDepositShares: destinationTransferShares, - destinationDepositAssets: destinationTransferShares, - sourceUnderlyingWithdrawCount: sourceWithdrawals.length, - sourceUnderlyingWithdrawAssets: sourceWithdrawAssets, - destinationUnderlyingDepositCount: 0, - destinationUnderlyingDepositAssets: ZERO - } - ] - } - - return [] - }) - - return candidates.length === 1 ? candidates[0] : null -} - -function detectKnownCrossFamilyRollover( - txEvents: TRawPnlEvent[], - userAddress: string, - vaultMetadata: Map -): TDetectedKnownMigration | null { - return ( - detectKnownMigratorCrossFamilyRollover(txEvents, userAddress) ?? - detectKnownCompatibleAssetVaultCrossFamilyRollover(txEvents, userAddress, vaultMetadata) - ) -} - -function processKnownCrossFamilyRollover( - ledgers: Map, - txEvents: TRawPnlEvent[], - userAddress: string, - vaultMetadata: Map -): Set { - const migration = detectKnownCrossFamilyRollover(txEvents, userAddress, vaultMetadata) - - if (!migration) { - return new Set() - } - - const sourceLedger = getOrCreateLedger(ledgers, migration.chainId, migration.sourceFamilyVaultAddress) - const destinationLedger = getOrCreateLedger(ledgers, migration.chainId, migration.destinationFamilyVaultAddress) - const sourceFamilyEvents = txEvents.filter((event) => event.familyVaultAddress === migration.sourceFamilyVaultAddress) - const destinationFamilyEvents = txEvents.filter( - (event) => event.familyVaultAddress === migration.destinationFamilyVaultAddress - ) - const sourceShouldLog = shouldLogLotTransactions(sourceLedger, migration.transactionHash) - const destinationShouldLog = shouldLogLotTransactions(destinationLedger, migration.transactionHash) - const includeDetailedLotLogs = shouldIncludeDetailedLotLogs() - const sourceVaultLotsBefore = sourceShouldLog ? cloneLots(sourceLedger.vaultLots) : [] - const sourceStakedLotsBefore = sourceShouldLog ? cloneLots(sourceLedger.stakedLots) : [] - const destinationVaultLotsBefore = destinationShouldLog ? cloneLots(destinationLedger.vaultLots) : [] - const destinationStakedLotsBefore = destinationShouldLog ? cloneLots(destinationLedger.stakedLots) : [] - - sourceLedger.eventCounts.migrationsOut += 1 - destinationLedger.eventCounts.migrationsIn += 1 - sourceLedger.totalWithdrawnAssets += migration.sourceUnderlyingWithdrawAssets - sourceLedger.eventCounts.underlyingWithdrawals += migration.sourceUnderlyingWithdrawCount - destinationLedger.totalDepositedAssets += migration.destinationUnderlyingDepositAssets - destinationLedger.eventCounts.underlyingDeposits += migration.destinationUnderlyingDepositCount - - const sourceConsumed = consumeFromLocation(sourceLedger, 'vault', migration.sourceTransferShares) - const knownSourceLots = sourceConsumed.consumedLots.filter((lot) => lot.costBasis !== null) - const knownSourceShares = sumShares(knownSourceLots) - const knownDestinationShares = - migration.sourceTransferShares === ZERO - ? ZERO - : (migration.destinationDepositShares * knownSourceShares) / migration.sourceTransferShares - const rolledLots = redistributeLotsToTargetShares(knownSourceLots, knownDestinationShares) - const rolledShares = sumShares(rolledLots) - const unknownDestinationShares = migration.destinationDepositShares - rolledShares - const unmatchedSourceShares = migration.sourceTransferShares - sourceConsumed.consumedShares - - addLotsToLocation(destinationLedger, 'vault', rolledLots) - - if (unknownDestinationShares > ZERO) { - destinationLedger.unknownCostBasisTransferInCount += 1 - destinationLedger.unknownCostBasisTransferInShares += unknownDestinationShares - destinationLedger.unknownTransferInEntries.push({ - timestamp: migration.blockTimestamp, - shares: unknownDestinationShares, - location: 'vault' - }) - addLotsToLocation(destinationLedger, 'vault', [createLot(unknownDestinationShares, null, migration.blockTimestamp)]) - } - - if (unmatchedSourceShares > ZERO) { - sourceLedger.unmatchedTransferOutCount += 1 - sourceLedger.unmatchedTransferOutShares += unmatchedSourceShares - } - - sourceLedger.debugJournal.push({ - timestamp: migration.blockTimestamp, - txHash: migration.transactionHash, - familyVaultAddress: migration.sourceFamilyVaultAddress, - stakingVaultAddress: sourceLedger.stakingVaultAddress, - view: 'migrate_out', - hasAddressActivity: true, - rawEvents: countTxEventsByKind(sourceFamilyEvents), - depositShares: ZERO.toString(), - depositAssets: ZERO.toString(), - withdrawShares: ZERO.toString(), - withdrawAssets: ZERO.toString(), - stakeShares: ZERO.toString(), - unstakeShares: ZERO.toString(), - rewardInVaultShares: ZERO.toString(), - rewardInStakedShares: ZERO.toString(), - unknownInVaultShares: ZERO.toString(), - unknownInStakedShares: ZERO.toString(), - transferOutVaultShares: migration.sourceTransferShares.toString(), - transferOutStakedShares: ZERO.toString(), - realizedKnownShares: ZERO.toString(), - realizedProceedsAssets: ZERO.toString(), - realizedBasisAssets: ZERO.toString(), - realizedPnlAssets: ZERO.toString(), - vaultLotsBefore: summarizeLots(sourceVaultLotsBefore), - stakedLotsBefore: summarizeLots(sourceStakedLotsBefore), - vaultLotsAfter: summarizeLots(sourceLedger.vaultLots), - stakedLotsAfter: summarizeLots(sourceLedger.stakedLots) - }) - - destinationLedger.debugJournal.push({ - timestamp: migration.blockTimestamp, - txHash: migration.transactionHash, - familyVaultAddress: migration.destinationFamilyVaultAddress, - stakingVaultAddress: destinationLedger.stakingVaultAddress, - view: 'migrate_in->vault', - hasAddressActivity: true, - rawEvents: countTxEventsByKind(destinationFamilyEvents), - depositShares: migration.destinationDepositShares.toString(), - depositAssets: migration.destinationDepositAssets.toString(), - withdrawShares: ZERO.toString(), - withdrawAssets: ZERO.toString(), - stakeShares: ZERO.toString(), - unstakeShares: ZERO.toString(), - rewardInVaultShares: ZERO.toString(), - rewardInStakedShares: ZERO.toString(), - unknownInVaultShares: unknownDestinationShares.toString(), - unknownInStakedShares: ZERO.toString(), - transferOutVaultShares: ZERO.toString(), - transferOutStakedShares: ZERO.toString(), - realizedKnownShares: ZERO.toString(), - realizedProceedsAssets: ZERO.toString(), - realizedBasisAssets: ZERO.toString(), - realizedPnlAssets: ZERO.toString(), - vaultLotsBefore: summarizeLots(destinationVaultLotsBefore), - stakedLotsBefore: summarizeLots(destinationStakedLotsBefore), - vaultLotsAfter: summarizeLots(destinationLedger.vaultLots), - stakedLotsAfter: summarizeLots(destinationLedger.stakedLots) - }) - - if (sourceShouldLog) { - debugLog('pnl-lots', 'processed family transaction', { - chainId: migration.chainId, - familyVaultAddress: migration.sourceFamilyVaultAddress, - stakingVaultAddress: sourceLedger.stakingVaultAddress, - transactionHash: migration.transactionHash, - blockTimestamp: migration.blockTimestamp, - view: 'migrate_out', - sourceTransferShares: migration.sourceTransferShares.toString(), - knownRolledSourceShares: knownSourceShares.toString(), - unmatchedSourceShares: unmatchedSourceShares.toString(), - vaultLotsBefore: summarizeLots(sourceVaultLotsBefore), - stakedLotsBefore: summarizeLots(sourceStakedLotsBefore), - vaultLotsAfter: summarizeLots(sourceLedger.vaultLots), - stakedLotsAfter: summarizeLots(sourceLedger.stakedLots), - events: includeDetailedLotLogs ? sourceFamilyEvents.map(serializeRawEvent) : undefined, - vaultLotEntriesBefore: includeDetailedLotLogs ? serializeLots(sourceVaultLotsBefore) : undefined, - stakedLotEntriesBefore: includeDetailedLotLogs ? serializeLots(sourceStakedLotsBefore) : undefined, - vaultLotEntriesAfter: includeDetailedLotLogs ? serializeLots(sourceLedger.vaultLots) : undefined, - stakedLotEntriesAfter: includeDetailedLotLogs ? serializeLots(sourceLedger.stakedLots) : undefined - }) - } - - if (destinationShouldLog) { - debugLog('pnl-lots', 'processed family transaction', { - chainId: migration.chainId, - familyVaultAddress: migration.destinationFamilyVaultAddress, - stakingVaultAddress: destinationLedger.stakingVaultAddress, - transactionHash: migration.transactionHash, - blockTimestamp: migration.blockTimestamp, - view: 'migrate_in->vault', - sourceFamilyVaultAddress: migration.sourceFamilyVaultAddress, - destinationDepositShares: migration.destinationDepositShares.toString(), - destinationDepositAssets: migration.destinationDepositAssets.toString(), - rolledKnownShares: rolledShares.toString(), - rolledKnownCostBasisAssets: sumKnownCostBasis(rolledLots).toString(), - unknownDestinationShares: unknownDestinationShares.toString(), - vaultLotsBefore: summarizeLots(destinationVaultLotsBefore), - stakedLotsBefore: summarizeLots(destinationStakedLotsBefore), - vaultLotsAfter: summarizeLots(destinationLedger.vaultLots), - stakedLotsAfter: summarizeLots(destinationLedger.stakedLots), - events: includeDetailedLotLogs ? destinationFamilyEvents.map(serializeRawEvent) : undefined, - vaultLotEntriesBefore: includeDetailedLotLogs ? serializeLots(destinationVaultLotsBefore) : undefined, - stakedLotEntriesBefore: includeDetailedLotLogs ? serializeLots(destinationStakedLotsBefore) : undefined, - vaultLotEntriesAfter: includeDetailedLotLogs ? serializeLots(destinationLedger.vaultLots) : undefined, - stakedLotEntriesAfter: includeDetailedLotLogs ? serializeLots(destinationLedger.stakedLots) : undefined - }) - } - - return new Set([ - toVaultKey(migration.chainId, migration.sourceFamilyVaultAddress), - toVaultKey(migration.chainId, migration.destinationFamilyVaultAddress) - ]) -} - -function isSameVaultRolloverTransaction(parts: { - txFamilyEvents: TRawPnlEvent[] - txUnderlyingDeposits: Array> - txUnderlyingWithdrawals: Array> - familyVaultAddress: string - totalUnderlyingDepositAssets: bigint - totalUnderlyingWithdrawAssets: bigint - stakingDelta: bigint -}): boolean { - return ( - parts.txFamilyEvents.length >= 4 && - parts.txFamilyEvents.every((event) => event.vaultAddress === parts.familyVaultAddress) && - parts.txUnderlyingDeposits.length === 1 && - parts.txUnderlyingWithdrawals.length === 1 && - parts.totalUnderlyingDepositAssets > ZERO && - parts.totalUnderlyingDepositAssets === parts.totalUnderlyingWithdrawAssets && - parts.stakingDelta === ZERO - ) -} - -function processFamilyTransaction(ledger: FamilyPnlLedger, txFamilyEvents: TRawPnlEvent[], userAddress: string): void { - const familyVaultAddress = ledger.vaultAddress - const stakingVaultAddress = ledger.stakingVaultAddress - const transactionHash = txFamilyEvents[0]?.transactionHash ?? '' - const shouldLogTransactionLots = shouldLogLotTransactions(ledger, transactionHash) - const includeDetailedLotLogs = shouldIncludeDetailedLotLogs() - const vaultLotsBefore = shouldLogTransactionLots ? cloneLots(ledger.vaultLots) : [] - const stakedLotsBefore = shouldLogTransactionLots ? cloneLots(ledger.stakedLots) : [] - const familyHasAddressActivity = hasAddressScopedFamilyActivity( - txFamilyEvents, - familyVaultAddress, - stakingVaultAddress - ) - const txUnderlyingDeposits = txFamilyEvents.filter( - (event): event is Extract => - event.kind === 'deposit' && - event.vaultAddress === familyVaultAddress && - shouldIncludeFamilyVaultAssetEvent(event, familyHasAddressActivity) - ) - const txUnderlyingWithdrawals = txFamilyEvents.filter( - (event): event is Extract => - event.kind === 'withdrawal' && - event.vaultAddress === familyVaultAddress && - shouldIncludeFamilyVaultAssetEvent(event, familyHasAddressActivity) - ) - const underlyingDelta = calculateUserContractDelta(txFamilyEvents, userAddress, familyVaultAddress) - const stakingDelta = - stakingVaultAddress === null ? ZERO : calculateUserContractDelta(txFamilyEvents, userAddress, stakingVaultAddress) - const totalUnderlyingDepositShares = sumEventShares(txUnderlyingDeposits) - const totalUnderlyingDepositAssets = sumEventAssets(txUnderlyingDeposits) - const totalUnderlyingWithdrawShares = sumEventShares(txUnderlyingWithdrawals) - const totalUnderlyingWithdrawAssets = sumEventAssets(txUnderlyingWithdrawals) - - if ( - isSameVaultRolloverTransaction({ - txFamilyEvents, - txUnderlyingDeposits, - txUnderlyingWithdrawals, - familyVaultAddress, - totalUnderlyingDepositAssets, - totalUnderlyingWithdrawAssets, - stakingDelta - }) - ) { - const vaultConsumed = consumeLots(ledger.vaultLots, totalUnderlyingWithdrawShares) - const stakedConsumed = consumeLots(ledger.stakedLots, ZERO) - const consumedShares = vaultConsumed.consumedShares + stakedConsumed.consumedShares - - if (consumedShares === totalUnderlyingWithdrawShares) { - const rolledLots = redistributeLotsToTargetShares(vaultConsumed.consumedLots, totalUnderlyingDepositShares) - - ledger.vaultLots = [...vaultConsumed.nextLots, ...rolledLots] - - ledger.debugJournal.push({ - timestamp: txFamilyEvents[0]?.blockTimestamp ?? 0, - txHash: transactionHash, - familyVaultAddress, - stakingVaultAddress, - view: 'same_vault_rollover->vault', - hasAddressActivity: familyHasAddressActivity, - rawEvents: countTxEventsByKind(txFamilyEvents), - depositShares: totalUnderlyingDepositShares.toString(), - depositAssets: totalUnderlyingDepositAssets.toString(), - withdrawShares: totalUnderlyingWithdrawShares.toString(), - withdrawAssets: totalUnderlyingWithdrawAssets.toString(), - stakeShares: ZERO.toString(), - unstakeShares: ZERO.toString(), - rewardInVaultShares: ZERO.toString(), - rewardInStakedShares: ZERO.toString(), - unknownInVaultShares: ZERO.toString(), - unknownInStakedShares: ZERO.toString(), - transferOutVaultShares: ZERO.toString(), - transferOutStakedShares: ZERO.toString(), - realizedKnownShares: ZERO.toString(), - realizedProceedsAssets: ZERO.toString(), - realizedBasisAssets: ZERO.toString(), - realizedPnlAssets: ZERO.toString(), - vaultLotsBefore: summarizeLots(vaultLotsBefore), - stakedLotsBefore: summarizeLots(stakedLotsBefore), - vaultLotsAfter: summarizeLots(ledger.vaultLots), - stakedLotsAfter: summarizeLots(ledger.stakedLots) - }) - - if (shouldLogTransactionLots) { - debugLog('pnl-lots', 'processed family transaction', { - chainId: ledger.chainId, - familyVaultAddress, - stakingVaultAddress, - transactionHash, - blockTimestamp: txFamilyEvents[0]?.blockTimestamp ?? null, - familyHasAddressActivity, - eventCount: txFamilyEvents.length, - view: 'same_vault_rollover->vault', - totalUnderlyingDepositShares: totalUnderlyingDepositShares.toString(), - totalUnderlyingDepositAssets: totalUnderlyingDepositAssets.toString(), - totalUnderlyingWithdrawShares: totalUnderlyingWithdrawShares.toString(), - totalUnderlyingWithdrawAssets: totalUnderlyingWithdrawAssets.toString(), - rolledLotCount: rolledLots.length, - rolledKnownCostBasisAssets: sumKnownCostBasis(rolledLots.filter((lot) => lot.costBasis !== null)).toString(), - vaultLotsBefore: summarizeLots(vaultLotsBefore), - stakedLotsBefore: summarizeLots(stakedLotsBefore), - vaultLotsAfter: summarizeLots(ledger.vaultLots), - stakedLotsAfter: summarizeLots(ledger.stakedLots), - events: includeDetailedLotLogs ? txFamilyEvents.map(serializeRawEvent) : undefined, - vaultLotEntriesBefore: includeDetailedLotLogs ? serializeLots(vaultLotsBefore) : undefined, - stakedLotEntriesBefore: includeDetailedLotLogs ? serializeLots(stakedLotsBefore) : undefined, - vaultLotEntriesAfter: includeDetailedLotLogs ? serializeLots(ledger.vaultLots) : undefined, - stakedLotEntriesAfter: includeDetailedLotLogs ? serializeLots(ledger.stakedLots) : undefined - }) - } - - return - } - } - - ledger.totalDepositedAssets += totalUnderlyingDepositAssets - ledger.totalWithdrawnAssets += totalUnderlyingWithdrawAssets - ledger.eventCounts.underlyingDeposits += txUnderlyingDeposits.length - ledger.eventCounts.underlyingWithdrawals += txUnderlyingWithdrawals.length - - const stakingRealizeShares = minBigInt(negativeBigIntMagnitude(stakingDelta), totalUnderlyingWithdrawShares) - const vaultRealizeShares = totalUnderlyingWithdrawShares - stakingRealizeShares - - const realization = applyRealization( - ledger, - totalUnderlyingWithdrawShares, - totalUnderlyingWithdrawAssets, - stakingRealizeShares, - vaultRealizeShares, - txFamilyEvents[0]?.blockTimestamp ?? 0 - ) - - const acquiredToStaked = minBigInt(positiveBigInt(stakingDelta), totalUnderlyingDepositShares) - const acquiredToVault = minBigInt(positiveBigInt(underlyingDelta), totalUnderlyingDepositShares - acquiredToStaked) - - addAcquisitionLots( - ledger, - totalUnderlyingDepositShares, - totalUnderlyingDepositAssets, - acquiredToStaked, - acquiredToVault, - txFamilyEvents[0]?.blockTimestamp ?? 0 - ) - - const remainingUnderlyingDelta = underlyingDelta - acquiredToVault + vaultRealizeShares - const remainingStakingDelta = stakingDelta - acquiredToStaked + stakingRealizeShares - const stakeShares = minBigInt( - negativeBigIntMagnitude(remainingUnderlyingDelta), - positiveBigInt(remainingStakingDelta) - ) - const remainingUnderlyingAfterWrap = remainingUnderlyingDelta + stakeShares - const remainingStakingAfterWrap = remainingStakingDelta - stakeShares - const unstakeShares = minBigInt( - negativeBigIntMagnitude(remainingStakingAfterWrap), - positiveBigInt(remainingUnderlyingAfterWrap) - ) - const finalUnderlyingDelta = remainingUnderlyingAfterWrap - unstakeShares - const finalStakingDelta = remainingStakingAfterWrap + unstakeShares - const rewardAllocation = allocateRecognizedRewardTransferIns( - getRecognizedRewardTransferIns(txFamilyEvents, userAddress, familyVaultAddress, stakingVaultAddress), - positiveBigInt(finalUnderlyingDelta), - positiveBigInt(finalStakingDelta) - ) - const rewardInVaultShares = rewardAllocation.rewardInVaultShares - const rewardInStakedShares = rewardAllocation.rewardInStakedShares - const unknownInVaultShares = positiveBigInt(finalUnderlyingDelta) - rewardInVaultShares - const unknownInStakedShares = positiveBigInt(finalStakingDelta) - rewardInStakedShares - const transferOutVaultShares = negativeBigIntMagnitude(finalUnderlyingDelta) - const transferOutStakedShares = negativeBigIntMagnitude(finalStakingDelta) - - if (stakeShares > ZERO) { - ledger.eventCounts.stakes += 1 - moveBetweenLocations(ledger, 'vault', 'staked', stakeShares) - } - - if (unstakeShares > ZERO) { - ledger.eventCounts.unstakes += 1 - moveBetweenLocations(ledger, 'staked', 'vault', unstakeShares) - } - - rewardAllocation.allocatedEntries.forEach((entry) => { - handleRewardTransferIn(ledger, entry.location, entry.shares, entry.timestamp, entry.distributor) - }) - handleUnknownTransferIn(ledger, 'vault', unknownInVaultShares, txFamilyEvents[0]?.blockTimestamp ?? 0) - handleUnknownTransferIn(ledger, 'staked', unknownInStakedShares, txFamilyEvents[0]?.blockTimestamp ?? 0) - handleExternalTransferOut(ledger, 'vault', transferOutVaultShares) - handleExternalTransferOut(ledger, 'staked', transferOutStakedShares) - - ledger.debugJournal.push({ - timestamp: txFamilyEvents[0]?.blockTimestamp ?? 0, - txHash: transactionHash, - familyVaultAddress, - stakingVaultAddress, - view: buildJournalView({ - acquiredToVault, - acquiredToStaked, - vaultRealizeShares, - stakingRealizeShares, - stakeShares, - unstakeShares, - rewardInVaultShares, - rewardInStakedShares, - unknownInVaultShares, - unknownInStakedShares, - transferOutVaultShares, - transferOutStakedShares - }), - hasAddressActivity: familyHasAddressActivity, - rawEvents: countTxEventsByKind(txFamilyEvents), - depositShares: totalUnderlyingDepositShares.toString(), - depositAssets: totalUnderlyingDepositAssets.toString(), - withdrawShares: totalUnderlyingWithdrawShares.toString(), - withdrawAssets: totalUnderlyingWithdrawAssets.toString(), - stakeShares: stakeShares.toString(), - unstakeShares: unstakeShares.toString(), - rewardInVaultShares: rewardInVaultShares.toString(), - rewardInStakedShares: rewardInStakedShares.toString(), - unknownInVaultShares: unknownInVaultShares.toString(), - unknownInStakedShares: unknownInStakedShares.toString(), - transferOutVaultShares: transferOutVaultShares.toString(), - transferOutStakedShares: transferOutStakedShares.toString(), - realizedKnownShares: realization.knownShares.toString(), - realizedProceedsAssets: realization.knownProceedsAssets.toString(), - realizedBasisAssets: realization.knownCostBasisAssets.toString(), - realizedPnlAssets: realization.realizedPnlAssets.toString(), - vaultLotsBefore: summarizeLots(vaultLotsBefore), - stakedLotsBefore: summarizeLots(stakedLotsBefore), - vaultLotsAfter: summarizeLots(ledger.vaultLots), - stakedLotsAfter: summarizeLots(ledger.stakedLots) - }) - - if (shouldLogTransactionLots) { - debugLog('pnl-lots', 'processed family transaction', { - chainId: ledger.chainId, - familyVaultAddress, - stakingVaultAddress, - transactionHash, - blockTimestamp: txFamilyEvents[0]?.blockTimestamp ?? null, - familyHasAddressActivity, - eventCount: txFamilyEvents.length, - underlyingDelta: underlyingDelta.toString(), - stakingDelta: stakingDelta.toString(), - totalUnderlyingDepositShares: totalUnderlyingDepositShares.toString(), - totalUnderlyingDepositAssets: totalUnderlyingDepositAssets.toString(), - totalUnderlyingWithdrawShares: totalUnderlyingWithdrawShares.toString(), - totalUnderlyingWithdrawAssets: totalUnderlyingWithdrawAssets.toString(), - acquiredToVault: acquiredToVault.toString(), - acquiredToStaked: acquiredToStaked.toString(), - vaultRealizeShares: vaultRealizeShares.toString(), - stakingRealizeShares: stakingRealizeShares.toString(), - stakeShares: stakeShares.toString(), - unstakeShares: unstakeShares.toString(), - rewardInVaultShares: rewardInVaultShares.toString(), - rewardInStakedShares: rewardInStakedShares.toString(), - finalUnderlyingDelta: finalUnderlyingDelta.toString(), - finalStakingDelta: finalStakingDelta.toString(), - realizedKnownShares: realization.knownShares.toString(), - realizedProceedsAssets: realization.knownProceedsAssets.toString(), - realizedBasisAssets: realization.knownCostBasisAssets.toString(), - realizedPnlAssets: realization.realizedPnlAssets.toString(), - vaultLotsBefore: summarizeLots(vaultLotsBefore), - stakedLotsBefore: summarizeLots(stakedLotsBefore), - vaultLotsAfter: summarizeLots(ledger.vaultLots), - stakedLotsAfter: summarizeLots(ledger.stakedLots), - events: includeDetailedLotLogs ? txFamilyEvents.map(serializeRawEvent) : undefined, - vaultLotEntriesBefore: includeDetailedLotLogs ? serializeLots(vaultLotsBefore) : undefined, - stakedLotEntriesBefore: includeDetailedLotLogs ? serializeLots(stakedLotsBefore) : undefined, - vaultLotEntriesAfter: includeDetailedLotLogs ? serializeLots(ledger.vaultLots) : undefined, - stakedLotEntriesAfter: includeDetailedLotLogs ? serializeLots(ledger.stakedLots) : undefined - }) - } -} - -export function processRawPnlEvents( - events: TRawPnlEvent[], - userAddress: string, - vaultMetadata: Map = new Map() -): Map { - const ledgers = new Map() - const userAddressLower = userAddress.toLowerCase() - - // Process transactions in two passes: - // 1. detect cross-family migrations that should carry basis across vaults - // 2. interpret the remaining per-family activity as deposits, withdrawals, stakes, unstakes, or transfers - groupEventsByTransaction(events).forEach((txEvents) => { - const handledFamilyKeys = processKnownCrossFamilyRollover(ledgers, txEvents, userAddressLower, vaultMetadata) - - groupTransactionEventsByFamily(txEvents).forEach((txFamilyEvents) => { - const familyVaultAddress = txFamilyEvents[0]?.familyVaultAddress - const chainId = txFamilyEvents[0]?.chainId - - if (!familyVaultAddress || chainId === undefined) { - return - } - - if (handledFamilyKeys.has(toVaultKey(chainId, familyVaultAddress))) { - return - } - - const ledger = getOrCreateLedger(ledgers, chainId, familyVaultAddress) - processFamilyTransaction(ledger, txFamilyEvents, userAddressLower) - }) - }) - - return Array.from(ledgers.entries()).reduce>((filtered, [key, ledger]) => { - if (!isLedgerEmpty(ledger)) { - filtered.set(key, ledger) - } - - return filtered - }, new Map()) -} - -async function computeHoldingsPnLArtifacts( - userAddress: string, - version: VaultVersion, - unknownTransferInPnlMode: UnknownTransferInPnlMode, - fetchType: HoldingsEventFetchType, - paginationMode: HoldingsEventPaginationMode -): Promise { - debugLog('pnl', 'starting holdings pnl calculation', { version, unknownTransferInPnlMode, fetchType, paginationMode }) - const rawContext = await fetchRawUserPnlEvents(userAddress, 'all', undefined, fetchType, paginationMode) - debugLog('pnl', 'loaded raw pnl event context', { - addressDeposits: rawContext.addressEvents.deposits.length, - addressWithdrawals: rawContext.addressEvents.withdrawals.length, - addressTransfersIn: rawContext.addressEvents.transfersIn.length, - addressTransfersOut: rawContext.addressEvents.transfersOut.length, - txDeposits: rawContext.transactionEvents.deposits.length, - txWithdrawals: rawContext.transactionEvents.withdrawals.length, - txTransfers: rawContext.transactionEvents.transfers.length - }) - const rawEvents = await enrichRawPnlEventsWithCowTradeAcquisitions(buildRawPnlEvents(rawContext), userAddress) - const rawVaultIdentifiers = Array.from( - rawEvents.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()) - ).map(([, identifier]) => identifier) - const resolvedVaultMetadata = await fetchMultipleVaultsMetadata(rawVaultIdentifiers) - const debugTxLedgerKeys = getDebugTxLedgerKeys(rawEvents) - const rawLedgers = processRawPnlEvents(rawEvents, userAddress, resolvedVaultMetadata) - const directInteractionLedgers = filterDirectInteractionLedgers(rawLedgers) - const ledgers = filterRelevantHoldingsLedgers(rawLedgers) - const vaults = Array.from(ledgers.values()) - const currentTimestamp = Math.floor(Date.now() / 1000) - debugLog('pnl', 'processed raw pnl events into ledgers', { - rawLedgers: rawLedgers.size, - directInteractionLedgers: directInteractionLedgers.size, - relevantHoldingsLedgers: ledgers.size, - currentTimestamp - }) - - if (vaults.length === 0) { - debugLog('pnl', 'no pnl ledgers produced for address') - return { - userAddress, - version, - unknownTransferInPnlMode, - currentTimestamp, - vaults: [], - vaultMetadata: new Map(), - ppsData: new Map>(), - priceData: new Map>(), - debugTxLedgerKeys - } - } - - const filteredVaults = filterVaultsByAuthoritativeVersion(vaults, resolvedVaultMetadata, version) - const filteredVaultIdentifiers = filteredVaults.map((vault) => ({ - chainId: vault.chainId, - vaultAddress: vault.vaultAddress - })) - const baseVaultMetadata = filteredVaultIdentifiers.reduce>((filtered, vault) => { - const key = toVaultKey(vault.chainId, vault.vaultAddress) - const metadata = resolvedVaultMetadata.get(key) - if (metadata) { - filtered.set(key, metadata) - } - return filtered - }, new Map()) - const vaultMetadata = await resolveNestedVaultAssetMetadata(baseVaultMetadata) - - if (version !== 'all') { - debugLog('pnl', 'filtered pnl ledgers by authoritative vault version', { - requestedVersion: version, - ledgersBefore: vaults.length, - ledgersAfter: filteredVaults.length, - metadataResolved: resolvedVaultMetadata.size - }) - } - - if (filteredVaults.length === 0) { - debugLog('pnl', 'no pnl ledgers matched requested version after authoritative vault filtering', { - requestedVersion: version, - ledgersBefore: vaults.length - }) - return { - userAddress, - version, - unknownTransferInPnlMode, - currentTimestamp, - vaults: [], - vaultMetadata, - ppsData: new Map>(), - priceData: new Map>(), - debugTxLedgerKeys - } - } - - const historicalPriceLedgers = filteredVaults.filter((vault) => !isCurrentTransferOnlyHoldingsLedger(vault)) - const timestamps = [ - ...new Set([ - currentTimestamp, - ...historicalPriceLedgers.flatMap((vault) => vault.realizedEntries.map((entry) => entry.timestamp)), - ...historicalPriceLedgers.flatMap((vault) => - [...vault.vaultLots, ...vault.stakedLots] - .map((lot) => lot.acquiredAt) - .filter((timestamp): timestamp is number => timestamp !== undefined) - ), - ...historicalPriceLedgers.flatMap((vault) => - vault.realizedEntries.flatMap((entry) => - entry.consumedLots - .map((lot) => lot.acquiredAt) - .filter((timestamp): timestamp is number => timestamp !== undefined) - ) - ), - ...historicalPriceLedgers.flatMap((vault) => vault.unknownTransferInEntries.map((entry) => entry.timestamp)), - ...historicalPriceLedgers.flatMap((vault) => vault.unknownWithdrawalEntries.map((entry) => entry.timestamp)), - ...historicalPriceLedgers.flatMap((vault) => - vault.unknownWithdrawalEntries.flatMap((entry) => - entry.consumedLots - .map((lot) => lot.acquiredAt) - .filter((timestamp): timestamp is number => timestamp !== undefined) - ) - ) - ]) - ].sort((a, b) => a - b) - const seenTokens = new Set() - const basePriceRequests = filteredVaultIdentifiers.reduce((allTokens, vault) => { - const metadata = vaultMetadata.get(toVaultKey(vault.chainId, vault.vaultAddress)) - - if (!metadata) { - return allTokens - } - - const tokenKey = `${metadata.chainId}:${metadata.token.address.toLowerCase()}` - - if (seenTokens.has(tokenKey)) { - return allTokens - } - - seenTokens.add(tokenKey) - allTokens.push({ - chainId: metadata.chainId, - address: metadata.token.address, - timestamps, - uncachedTimestamps: [currentTimestamp] - }) - return allTokens - }, []) - const priceRequests = expandNestedVaultAssetPriceRequests(basePriceRequests, vaultMetadata) - const ppsIdentifiers = mergeVaultIdentifiers([ - ...filteredVaultIdentifiers, - ...getNestedVaultPpsIdentifiersFromPriceRequests(basePriceRequests, vaultMetadata) - ]) - const [ppsData, fetchedPriceData] = await Promise.all([ - fetchMultipleVaultsPPS(ppsIdentifiers), - fetchHistoricalPricesForTokenTimestamps(priceRequests) - ]) - const priceData = deriveNestedVaultAssetPriceData({ - priceData: fetchedPriceData, - priceRequests, - vaultMetadata, - ppsData - }) - debugLog('pnl', 'resolved vault metadata and PPS', { - vaults: ppsIdentifiers.length, - metadataResolved: vaultMetadata.size, - ppsResolved: ppsData.size, - emptyPpsTimelines: Array.from(ppsData.values()).filter((timeline) => timeline.size === 0).length - }) - debugLog('pnl', 'resolved token prices for pnl', { - tokens: priceRequests.length, - timestamps: timestamps.length, - priceKeys: priceData.size - }) - - return { - userAddress, - version, - unknownTransferInPnlMode, - currentTimestamp, - vaults: filteredVaults, - vaultMetadata, - ppsData, - priceData, - debugTxLedgerKeys - } -} - -function materializeHoldingsPnLVault(vault: FamilyPnlLedger, artifacts: TPnlComputationArtifacts): HoldingsPnLVault { - const { currentTimestamp, debugTxLedgerKeys, priceData, ppsData, unknownTransferInPnlMode, vaultMetadata } = artifacts - const vaultKey = toVaultKey(vault.chainId, vault.vaultAddress) - const metadata = vaultMetadata.get(vaultKey) ?? null - const ppsMap = ppsData.get(vaultKey) - const pricePerShare = ppsMap ? getPPS(ppsMap, currentTimestamp) : null - const priceKey = metadata ? `${getChainPrefix(vault.chainId)}:${metadata.token.address.toLowerCase()}` : null - const tokenPriceMap = priceKey ? priceData.get(priceKey) : null - const tokenPrice = tokenPriceMap ? getPriceAtTimestamp(tokenPriceMap, currentTimestamp) : 0 - const resolvedPricePerShare = pricePerShare ?? 0 - const vaultSharesRaw = sumShares(vault.vaultLots) - const stakedSharesRaw = sumShares(vault.stakedLots) - const totalSharesRaw = vaultSharesRaw + stakedSharesRaw - const knownLots = [...vault.vaultLots, ...vault.stakedLots].filter((lot) => lot.costBasis !== null) - const unknownLots = [...vault.vaultLots, ...vault.stakedLots].filter((lot) => lot.costBasis === null) - const knownSharesRaw = sumShares(knownLots) - const unknownSharesRaw = sumShares(unknownLots) - - if (!metadata) { - if (shouldLogFinalLots(vault, debugTxLedgerKeys)) { - const includeDetailedLotLogs = shouldIncludeDetailedLotLogs() - debugTable( - 'pnl-journal', - `family transaction journal ${vault.vaultAddress}${vault.stakingVaultAddress ? ` (staking ${vault.stakingVaultAddress})` : ''}`, - serializeJournalRows(vault.debugJournal) - ) - debugLog('pnl-lots', 'final family lots missing metadata', { - chainId: vault.chainId, - familyVaultAddress: vault.vaultAddress, - stakingVaultAddress: vault.stakingVaultAddress, - status: 'missing_metadata', - vaultLots: summarizeLots(vault.vaultLots), - stakedLots: summarizeLots(vault.stakedLots), - vaultLotEntries: includeDetailedLotLogs ? serializeLots(vault.vaultLots) : undefined, - stakedLotEntries: includeDetailedLotLogs ? serializeLots(vault.stakedLots) : undefined, - realizedEntries: vault.realizedEntries.map((entry) => ({ - timestamp: entry.timestamp, - proceedsAssets: entry.proceedsAssets.toString(), - basisAssets: entry.basisAssets.toString(), - pnlAssets: entry.pnlAssets.toString() - })), - unknownCostBasisTransferInShares: vault.unknownCostBasisTransferInShares.toString(), - unmatchedTransferOutShares: vault.unmatchedTransferOutShares.toString() - }) - } - - return createMissingMetadataPnlVault(vault, unknownTransferInPnlMode, tokenPrice, resolvedPricePerShare) - } - - const vaultSharesFormatted = formatAmount(vaultSharesRaw, metadata.decimals) - const stakedSharesFormatted = formatAmount(stakedSharesRaw, metadata.decimals) - const sharesFormatted = formatAmount(totalSharesRaw, metadata.decimals) - const knownCostBasisUnderlying = formatAmount(sumKnownCostBasis(knownLots), metadata.token.decimals) - const vaultUnderlying = vaultSharesFormatted * resolvedPricePerShare - const stakedUnderlying = stakedSharesFormatted * resolvedPricePerShare - const currentUnderlying = vaultUnderlying + stakedUnderlying - const currentKnownUnderlying = formatAmount(knownSharesRaw, metadata.decimals) * resolvedPricePerShare - const currentUnknownUnderlying = formatAmount(unknownSharesRaw, metadata.decimals) * resolvedPricePerShare - const vaultValueUsd = vaultUnderlying * tokenPrice - const stakedValueUsd = stakedUnderlying * tokenPrice - const currentValueUsd = vaultValueUsd + stakedValueUsd - const isTransferOnlyHoldingsLedger = isCurrentTransferOnlyHoldingsLedger(vault) - - if (isTransferOnlyHoldingsLedger) { - const status = pricePerShare === null ? 'missing_pps' : tokenPrice > 0 ? 'ok' : 'missing_price' - const lightweightValuation = - unknownTransferInPnlMode === 'strict' - ? { - unknownCostBasisValueUsd: currentUnknownUnderlying * tokenPrice, - windfallPnlUsd: 0, - realizedPnlUnderlying: 0, - realizedPnlUsd: 0, - unrealizedPnlUnderlying: 0, - unrealizedPnlUsd: 0 - } - : unknownTransferInPnlMode === 'zero_basis' - ? { - unknownCostBasisValueUsd: 0, - windfallPnlUsd: 0, - realizedPnlUnderlying: 0, - realizedPnlUsd: 0, - unrealizedPnlUnderlying: currentUnknownUnderlying, - unrealizedPnlUsd: currentValueUsd - } - : { - unknownCostBasisValueUsd: 0, - windfallPnlUsd: currentValueUsd, - realizedPnlUnderlying: 0, - realizedPnlUsd: 0, - unrealizedPnlUnderlying: 0, - unrealizedPnlUsd: 0 - } - - return { - chainId: vault.chainId, - vaultAddress: vault.vaultAddress, - stakingVaultAddress: vault.stakingVaultAddress, - status, - costBasisStatus: 'partial', - unknownTransferInPnlMode, - shares: totalSharesRaw.toString(), - sharesFormatted, - vaultShares: vaultSharesRaw.toString(), - vaultSharesFormatted, - stakedShares: stakedSharesRaw.toString(), - stakedSharesFormatted, - knownCostBasisShares: knownSharesRaw.toString(), - unknownCostBasisShares: unknownSharesRaw.toString(), - pricePerShare: resolvedPricePerShare, - tokenPrice, - currentUnderlying, - vaultUnderlying, - stakedUnderlying, - currentKnownUnderlying, - currentUnknownUnderlying, - knownCostBasisUnderlying, - knownCostBasisUsd: 0, - currentValueUsd, - vaultValueUsd, - stakedValueUsd, - unknownCostBasisValueUsd: lightweightValuation.unknownCostBasisValueUsd, - windfallPnlUsd: lightweightValuation.windfallPnlUsd, - realizedPnlUnderlying: lightweightValuation.realizedPnlUnderlying, - realizedPnlUsd: lightweightValuation.realizedPnlUsd, - unrealizedPnlUnderlying: lightweightValuation.unrealizedPnlUnderlying, - unrealizedPnlUsd: lightweightValuation.unrealizedPnlUsd, - totalPnlUsd: lightweightValuation.realizedPnlUsd + lightweightValuation.unrealizedPnlUsd, - totalEconomicGainUsd: - lightweightValuation.realizedPnlUsd + - lightweightValuation.unrealizedPnlUsd + - lightweightValuation.windfallPnlUsd, - totalDepositedUnderlying: formatAmount(vault.totalDepositedAssets, metadata.token.decimals), - totalWithdrawnUnderlying: formatAmount(vault.totalWithdrawnAssets, metadata.token.decimals), - eventCounts: toHoldingsPnlEventCounts(vault), - metadata: { - symbol: metadata.token.symbol, - decimals: metadata.decimals, - assetDecimals: metadata.token.decimals, - tokenAddress: metadata.token.address, - category: metadata.category - } - } - } - - const knownCostBasisUsd = getKnownLotsCostBasisUsd(knownLots, metadata.token.decimals, tokenPriceMap, tokenPrice) - const baseRealizedPnlUnderlying = vault.realizedEntries.reduce( - (total, entry) => total + formatAmount(entry.pnlAssets, metadata.token.decimals), - 0 - ) - const baseRealizedPnlUsd = vault.realizedEntries.reduce((total, entry) => { - const realizedTokenPrice = tokenPriceMap ? getPriceAtTimestamp(tokenPriceMap, entry.timestamp) : 0 - const proceedsUsd = formatAmount(entry.proceedsAssets, metadata.token.decimals) * realizedTokenPrice - const basisUsd = getKnownLotsCostBasisUsd(entry.consumedLots, metadata.token.decimals, tokenPriceMap, tokenPrice) - return total + (proceedsUsd - basisUsd) - }, 0) - const baseUnrealizedPnlUnderlying = pricePerShare === null ? 0 : currentKnownUnderlying - knownCostBasisUnderlying - const baseUnrealizedPnlUsd = currentKnownUnderlying * tokenPrice - knownCostBasisUsd - const valuationState = applyUnknownTransferInModeAdjustment({ - unknownTransferInPnlMode, - vault, - unknownLots, - metadata, - ppsMap, - tokenPriceMap, - tokenPrice, - currentTimestamp, - resolvedPricePerShare, - currentUnknownUnderlying, - baseRealizedPnlUnderlying, - baseRealizedPnlUsd, - baseUnrealizedPnlUnderlying, - baseUnrealizedPnlUsd - }) - const costBasisStatus = - vault.unknownCostBasisTransferInCount === 0 && - vault.withdrawalsWithUnknownCostBasis === 0 && - vault.unmatchedTransferOutShares === ZERO - ? 'complete' - : 'partial' - const status = pricePerShare === null ? 'missing_pps' : tokenPrice > 0 ? 'ok' : 'missing_price' - - if (shouldLogFinalLots(vault, debugTxLedgerKeys)) { - const includeDetailedLotLogs = shouldIncludeDetailedLotLogs() - debugTable( - 'pnl-journal', - `family transaction journal ${vault.vaultAddress}${vault.stakingVaultAddress ? ` (staking ${vault.stakingVaultAddress})` : ''}`, - serializeJournalRows(vault.debugJournal, metadata.decimals, metadata.token.decimals) - ) - debugLog('pnl-lots', 'final family lots', { - chainId: vault.chainId, - familyVaultAddress: vault.vaultAddress, - stakingVaultAddress: vault.stakingVaultAddress, - status, - costBasisStatus, - symbol: metadata.token.symbol, - shareDecimals: metadata.decimals, - assetDecimals: metadata.token.decimals, - vaultLots: summarizeLots(vault.vaultLots), - stakedLots: summarizeLots(vault.stakedLots), - vaultLotEntries: includeDetailedLotLogs - ? serializeLots(vault.vaultLots, metadata.decimals, metadata.token.decimals) - : undefined, - stakedLotEntries: includeDetailedLotLogs - ? serializeLots(vault.stakedLots, metadata.decimals, metadata.token.decimals) - : undefined, - realizedEntries: vault.realizedEntries.map((entry) => ({ - timestamp: entry.timestamp, - proceedsAssets: entry.proceedsAssets.toString(), - basisAssets: entry.basisAssets.toString(), - pnlAssets: entry.pnlAssets.toString(), - pnlAssetsFormatted: formatAmount(entry.pnlAssets, metadata.token.decimals) - })), - unknownCostBasisTransferInShares: vault.unknownCostBasisTransferInShares.toString(), - unmatchedTransferOutShares: vault.unmatchedTransferOutShares.toString() - }) - } - - return { - chainId: vault.chainId, - vaultAddress: vault.vaultAddress, - stakingVaultAddress: vault.stakingVaultAddress, - status, - costBasisStatus, - unknownTransferInPnlMode, - shares: totalSharesRaw.toString(), - sharesFormatted, - vaultShares: vaultSharesRaw.toString(), - vaultSharesFormatted, - stakedShares: stakedSharesRaw.toString(), - stakedSharesFormatted, - knownCostBasisShares: knownSharesRaw.toString(), - unknownCostBasisShares: unknownSharesRaw.toString(), - pricePerShare: resolvedPricePerShare, - tokenPrice, - currentUnderlying, - vaultUnderlying, - stakedUnderlying, - currentKnownUnderlying, - currentUnknownUnderlying, - knownCostBasisUnderlying, - knownCostBasisUsd, - currentValueUsd, - vaultValueUsd, - stakedValueUsd, - unknownCostBasisValueUsd: valuationState.unknownCostBasisValueUsd, - windfallPnlUsd: valuationState.windfallPnlUsd, - realizedPnlUnderlying: valuationState.realizedPnlUnderlying, - realizedPnlUsd: valuationState.realizedPnlUsd, - unrealizedPnlUnderlying: valuationState.unrealizedPnlUnderlying, - unrealizedPnlUsd: valuationState.unrealizedPnlUsd, - totalPnlUsd: valuationState.realizedPnlUsd + valuationState.unrealizedPnlUsd, - totalEconomicGainUsd: - valuationState.realizedPnlUsd + valuationState.unrealizedPnlUsd + valuationState.windfallPnlUsd, - totalDepositedUnderlying: formatAmount(vault.totalDepositedAssets, metadata.token.decimals), - totalWithdrawnUnderlying: formatAmount(vault.totalWithdrawnAssets, metadata.token.decimals), - eventCounts: toHoldingsPnlEventCounts(vault), - metadata: { - symbol: metadata.token.symbol, - decimals: metadata.decimals, - assetDecimals: metadata.token.decimals, - tokenAddress: metadata.token.address, - category: metadata.category - } - } -} - -function materializeHoldingsPnLVaults(artifacts: TPnlComputationArtifacts): HoldingsPnLVault[] { - const pnlVaults = artifacts.vaults - .map((vault) => materializeHoldingsPnLVault(vault, artifacts)) - .sort((a, b) => b.currentValueUsd - a.currentValueUsd) - - debugLog('pnl', 'materialized pnl vault rows', { - vaults: pnlVaults.length, - missingMetadata: pnlVaults.filter((vault) => vault.status === 'missing_metadata').length, - missingPps: pnlVaults.filter((vault) => vault.status === 'missing_pps').length, - missingPrice: pnlVaults.filter((vault) => vault.status === 'missing_price').length, - partialCostBasis: pnlVaults.filter((vault) => vault.costBasisStatus === 'partial').length - }) - - return pnlVaults -} - -function materializeHoldingsPnLDrilldownVault( - vault: FamilyPnlLedger, - artifacts: TPnlComputationArtifacts -): HoldingsPnLDrilldownVault { - const baseVault = materializeHoldingsPnLVault(vault, artifacts) - const metadata = artifacts.vaultMetadata.get(toVaultKey(vault.chainId, vault.vaultAddress)) ?? null - - if (!metadata) { - return { - ...baseVault, - currentLots: { - vault: vault.vaultLots.map((lot, index) => ({ - index, - shares: lot.shares.toString(), - sharesFormatted: 0, - costBasis: lot.costBasis?.toString() ?? null, - costBasisFormatted: null, - acquiredAt: lot.acquiredAt ?? null, - costBasisUsd: null, - pricePerShareAtAcquisition: 0, - tokenPriceAtAcquisition: 0, - currentUnderlying: 0, - currentValueUsd: 0 - })), - staked: vault.stakedLots.map((lot, index) => ({ - index, - shares: lot.shares.toString(), - sharesFormatted: 0, - costBasis: lot.costBasis?.toString() ?? null, - costBasisFormatted: null, - acquiredAt: lot.acquiredAt ?? null, - costBasisUsd: null, - pricePerShareAtAcquisition: 0, - tokenPriceAtAcquisition: 0, - currentUnderlying: 0, - currentValueUsd: 0 - })) - }, - realizedEntries: [], - rewardTransferInEntries: [], - unknownTransferInEntries: [], - unknownWithdrawalEntries: [], - journal: [] - } - } - - const vaultKey = toVaultKey(vault.chainId, vault.vaultAddress) - const ppsMap = artifacts.ppsData.get(vaultKey) - const priceKey = `${getChainPrefix(vault.chainId)}:${metadata.token.address.toLowerCase()}` - const tokenPriceMap = artifacts.priceData.get(priceKey) ?? null - - return { - ...baseVault, - currentLots: { - vault: vault.vaultLots.map((lot, index) => - serializeLotForResponse({ - lot, - index, - shareDecimals: metadata.decimals, - assetDecimals: metadata.token.decimals, - resolvedPricePerShare: baseVault.pricePerShare, - currentTokenPrice: baseVault.tokenPrice, - ppsMap, - tokenPriceMap - }) - ), - staked: vault.stakedLots.map((lot, index) => - serializeLotForResponse({ - lot, - index, - shareDecimals: metadata.decimals, - assetDecimals: metadata.token.decimals, - resolvedPricePerShare: baseVault.pricePerShare, - currentTokenPrice: baseVault.tokenPrice, - ppsMap, - tokenPriceMap - }) - ) - }, - realizedEntries: vault.realizedEntries.map((entry) => - serializeRealizedEntryForResponse({ - entry, - shareDecimals: metadata.decimals, - assetDecimals: metadata.token.decimals, - currentTokenPrice: baseVault.tokenPrice, - tokenPriceMap, - resolvedPricePerShare: baseVault.pricePerShare, - ppsMap - }) - ), - rewardTransferInEntries: vault.rewardTransferInEntries.map((entry) => - serializeRewardTransferInEntryForResponse({ - entry, - shareDecimals: metadata.decimals, - currentTokenPrice: baseVault.tokenPrice, - resolvedPricePerShare: baseVault.pricePerShare, - ppsMap, - tokenPriceMap - }) - ), - unknownTransferInEntries: vault.unknownTransferInEntries.map((entry) => - serializeUnknownTransferInEntryForResponse({ - entry, - shareDecimals: metadata.decimals, - currentTokenPrice: baseVault.tokenPrice, - resolvedPricePerShare: baseVault.pricePerShare, - ppsMap, - tokenPriceMap - }) - ), - unknownWithdrawalEntries: vault.unknownWithdrawalEntries.map((entry) => - serializeUnknownWithdrawalEntryForResponse({ - entry, - shareDecimals: metadata.decimals, - assetDecimals: metadata.token.decimals, - currentTokenPrice: baseVault.tokenPrice, - tokenPriceMap, - resolvedPricePerShare: baseVault.pricePerShare, - ppsMap - }) - ), - journal: vault.debugJournal.map((row) => - serializeJournalEntryForResponse({ - row, - shareDecimals: metadata.decimals, - assetDecimals: metadata.token.decimals - }) - ) - } -} - -export async function getHoldingsPnL( - userAddress: string, - version: VaultVersion = 'all', - unknownTransferInPnlMode: UnknownTransferInPnlMode = 'windfall', - fetchType: HoldingsEventFetchType = 'seq', - paginationMode: HoldingsEventPaginationMode = 'paged' -): Promise { - const artifacts = await computeHoldingsPnLArtifacts( - userAddress, - version, - unknownTransferInPnlMode, - fetchType, - paginationMode - ) - - if (artifacts.vaults.length === 0) { - return createEmptyHoldingsPnlResponse(userAddress, version, unknownTransferInPnlMode) - } - - const pnlVaults = materializeHoldingsPnLVaults(artifacts) - const summary = summarizePnlVaults(pnlVaults) - debugLog('pnl', 'completed holdings pnl calculation', { - unknownTransferInPnlMode, - totalVaults: summary.totalVaults, - totalCurrentValueUsd: summary.totalCurrentValueUsd, - totalWindfallPnlUsd: summary.totalWindfallPnlUsd, - totalRealizedPnlUsd: summary.totalRealizedPnlUsd, - totalUnrealizedPnlUsd: summary.totalUnrealizedPnlUsd, - totalPnlUsd: summary.totalPnlUsd, - totalEconomicGainUsd: summary.totalEconomicGainUsd, - isComplete: summary.isComplete - }) - - return { - address: userAddress, - version, - unknownTransferInPnlMode, - generatedAt: new Date().toISOString(), - summary, - vaults: pnlVaults - } -} - -export async function getHoldingsPnLDrilldown( - userAddress: string, - version: VaultVersion = 'all', - unknownTransferInPnlMode: UnknownTransferInPnlMode = 'windfall', - fetchType: HoldingsEventFetchType = 'seq', - paginationMode: HoldingsEventPaginationMode = 'paged', - vaultFilter?: string | null -): Promise { - const artifacts = await computeHoldingsPnLArtifacts( - userAddress, - version, - unknownTransferInPnlMode, - fetchType, - paginationMode - ) - - if (artifacts.vaults.length === 0) { - return { - ...createEmptyHoldingsPnlResponse(userAddress, version, unknownTransferInPnlMode), - vaults: [] - } - } - - const filteredVaults = - vaultFilter === undefined || vaultFilter === null - ? artifacts.vaults - : artifacts.vaults.filter( - (vault) => - vault.vaultAddress === vaultFilter.toLowerCase() || vault.stakingVaultAddress === vaultFilter.toLowerCase() - ) - - const drilldownVaults = filteredVaults - .map((vault) => materializeHoldingsPnLDrilldownVault(vault, artifacts)) - .sort((a, b) => b.currentValueUsd - a.currentValueUsd) - const summary = summarizePnlVaults(drilldownVaults) - - debugLog('pnl', 'completed holdings pnl drilldown calculation', { - unknownTransferInPnlMode, - vaultFilter: vaultFilter?.toLowerCase() ?? null, - totalVaults: summary.totalVaults, - totalCurrentValueUsd: summary.totalCurrentValueUsd, - totalPnlUsd: summary.totalPnlUsd, - totalEconomicGainUsd: summary.totalEconomicGainUsd - }) - - return { - address: userAddress, - version, - unknownTransferInPnlMode, - generatedAt: new Date().toISOString(), - summary, - vaults: drilldownVaults - } -} 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/pnlSimple.test.ts b/api/lib/holdings/services/pnlSimple.test.ts index b547bc9f5..ba266dd8a 100644 --- a/api/lib/holdings/services/pnlSimple.test.ts +++ b/api/lib/holdings/services/pnlSimple.test.ts @@ -1,5 +1,7 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import type { VaultMetadata } from '../types' +import type { TransactionActivityEvents } from './graphql' +import { mergeAddressScopedRawPnlEventsWithTransactionActivity } from './pnlEvents' import { toVaultKey } from './pnlShared' import { buildProtocolReturnFamilyHistorySeries, @@ -11,6 +13,11 @@ import { } from './pnlSimple' import type { TRawPnlEvent } from './pnlTypes' +vi.mock('../db/connection', () => ({ + getPool: vi.fn(() => null), + isDatabaseEnabled: vi.fn(() => false) +})) + const USER = '0x1111111111111111111111111111111111111111' const OTHER = '0x2222222222222222222222222222222222222222' const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000' @@ -19,6 +26,12 @@ 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([ [ @@ -265,6 +278,146 @@ describe('pnl simple protocol return', () => { 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: [ @@ -376,6 +529,94 @@ describe('pnl simple protocol return', () => { 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' @@ -550,6 +791,48 @@ describe('pnl simple protocol return', () => { 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' @@ -645,6 +928,112 @@ describe('pnl simple protocol return', () => { 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' @@ -915,6 +1304,141 @@ describe('pnl simple protocol return', () => { 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: [ @@ -991,6 +1515,207 @@ describe('pnl simple protocol return', () => { 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: [ diff --git a/api/lib/holdings/services/pnlSimple.ts b/api/lib/holdings/services/pnlSimple.ts index 7ba7417d2..406ae39b2 100644 --- a/api/lib/holdings/services/pnlSimple.ts +++ b/api/lib/holdings/services/pnlSimple.ts @@ -1,7 +1,7 @@ import { formatUnits } from 'viem' -import { config } from '../config' +import { holdingsConfig } from '../config' import type { VaultMetadata } from '../types' -import { debugError, debugLog } from './debug' +import { debugError, debugLog, reportHoldingsProgress } from './debug' import { fetchHistoricalPricesForTokenTimestamps, getChainPrefix, @@ -9,7 +9,7 @@ import { type THistoricalPriceRequest } from './defillama' import { - fetchRawUserPnlEvents, + fetchActivityEventsByTransactionHashes, type HoldingsEventFetchType, type HoldingsEventPaginationMode, type VaultVersion @@ -20,19 +20,18 @@ import { timestampToDateString, toSettledDayTimestamp } from './holdings' -import { fetchMultipleVaultsPPS, getPPS } from './kong' +import { getPPS } from './kong' import { deriveNestedVaultAssetPriceData, expandNestedVaultAssetPriceRequests, - getAssetVaultMetadataLookupIdentifiers, getNestedVaultPpsIdentifiersFromPriceRequests, mergeVaultIdentifiers } from './nestedVaultPrices' -import { buildRawPnlEvents } from './pnl' +import { mergeAddressScopedRawPnlEventsWithTransactionActivity } from './pnlEvents' import { lowerCaseAddress, toVaultKey, ZERO } from './pnlShared' import type { TRawPnlEvent } from './pnlTypes' +import { getSettledVersionedPpsContext, getVaultIdentifiers } from './settledHoldingsContext' import { getStakingVaultAddress } from './staking' -import { fetchMultipleVaultsMetadata } from './vaults' type TProtocolReturnIssue = 'missing_metadata' | 'missing_pps' | 'missing_receipt_price' | 'unmatched_exit' @@ -225,6 +224,8 @@ export interface HoldingsPnLSimpleHistoryResponse { const SECONDS_PER_YEAR = 365 * 24 * 60 * 60 +type TProtocolReturnVaultFilter = { chainId: number; vaultAddress: string } + function emptyLedger(chainId: number, vaultAddress: string): TProtocolReturnLedger { return { chainId, @@ -354,72 +355,50 @@ function resolveRecommendedGrowthDisplay(composition: { stable: number; ethFamil } } -function getVaultIdentifiers(events: TRawPnlEvent[]): Array<{ chainId: number; vaultAddress: string }> { - 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 - }) - } +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 identifiers - }, new Map()) - ).map(([, identifier]) => identifier) + return requestedVaultFilters?.map((vault) => ({ + chainId: Number(vault.chainId), + vaultAddress: lowerCaseAddress(vault.vaultAddress) + })) } -function filterEventsByRequestedVault( +function filterEventsByRequestedVaults( events: TRawPnlEvent[], - requestedVault?: { chainId: number; vaultAddress: string } + requestedVaults?: TProtocolReturnVaultFilter[] ): TRawPnlEvent[] { - if (!requestedVault) { + if (!requestedVaults?.length) { return events } - return events.filter( - (event) => - event.chainId === requestedVault.chainId && - event.familyVaultAddress === lowerCaseAddress(requestedVault.vaultAddress) + 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 filterVaultIdentifiersByRequestedVault( - vaults: Array<{ chainId: number; vaultAddress: string }>, - requestedVault?: { chainId: number; vaultAddress: string } -): Array<{ chainId: number; vaultAddress: string }> { - if (!requestedVault) { +function filterVaultIdentifiersByRequestedVaults( + vaults: TProtocolReturnVaultFilter[], + requestedVaults?: TProtocolReturnVaultFilter[] +): TProtocolReturnVaultFilter[] { + if (!requestedVaults?.length) { return vaults } - const requestedVaultAddress = lowerCaseAddress(requestedVault.vaultAddress) - return vaults.filter( - (vault) => vault.chainId === requestedVault.chainId && vault.vaultAddress === requestedVaultAddress + 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 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 tokenPriceMapKey(metadata: VaultMetadata): string { return `${getChainPrefix(metadata.chainId)}:${metadata.token.address.toLowerCase()}` } @@ -473,19 +452,6 @@ export function buildReceiptPriceRequests(args: { })) } -async function resolveNestedVaultAssetMetadata( - vaultMetadata: Map -): Promise> { - const assetVaultIdentifiers = getAssetVaultMetadataLookupIdentifiers(vaultMetadata) - - if (assetVaultIdentifiers.length === 0) { - return vaultMetadata - } - - const assetVaultMetadata = await fetchMultipleVaultsMetadata(assetVaultIdentifiers, { skipSnapshotFallback: true }) - return new Map([...vaultMetadata, ...assetVaultMetadata]) -} - function countReceiptPricePoints(requests: TSimpleReceiptPriceRequest[]): number { return requests.reduce((total, request) => total + request.timestamps.length, 0) } @@ -496,7 +462,7 @@ async function fetchReceiptPrices(requests: TSimpleReceiptPriceRequest[]): Promi } try { - return await fetchHistoricalPricesForTokenTimestamps(requests) + 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, @@ -512,9 +478,10 @@ async function fetchEthReceiptPrices(timestamps: number[]): Promise | undefined, timestamp: number) 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: { @@ -791,11 +795,16 @@ function processEvent( 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(currentLedger, { + addReceipt(valuation.missingPps ? { ...currentLedger, missingPps: true } : currentLedger, { shares: event.shares, - baselineUnderlying: formatAmount(event.assets, assetDecimals), + baselineUnderlying: valuation.underlying, receiptTimestamp: event.blockTimestamp, receiptPriceUsd, receiptPriceEth, @@ -807,11 +816,16 @@ function processEvent( } if (event.kind === 'withdrawal') { + const valuation = valueDepositOrWithdrawalEvent(event, { + assetDecimals, + shareDecimals, + ppsMap + }) ledgers.set( vaultKey, - addExit(currentLedger, { + addExit(valuation.missingPps ? { ...currentLedger, missingPps: true } : currentLedger, { shares: event.shares, - exitUnderlying: formatAmount(event.assets, assetDecimals), + exitUnderlying: valuation.underlying, exitKind: 'withdrawal' }) ) @@ -899,6 +913,15 @@ function scaleBigInt(value: bigint, numerator: bigint, denominator: bigint): big 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 { @@ -1131,6 +1154,46 @@ function buildEffectiveSimpleFamilyEvents(txFamilyEvents: TRawPnlEvent[], userAd 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' && @@ -1469,18 +1532,75 @@ function normalizeStakingWrapperEvents(txFamilyEvents: TRawPnlEvent[], userAddre function getProtocolReturnTimestamps(events: TRawPnlEvent[], timeframe: '1y' | 'all'): number[] { if (timeframe === '1y') { - return generateDailyTimestamps(config.historyDays, 1).map((timestamp) => toSettledDayTimestamp(timestamp)) + return generateDailyTimestamps(holdingsConfig.historyDays, 1).map((timestamp) => toSettledDayTimestamp(timestamp)) } if (events.length === 0) { return [] } - const latestSettledTimestamp = generateDailyTimestamps(1, 1)[0] - const firstEventTimestamp = sortEvents(events)[0]?.blockTimestamp ?? latestSettledTimestamp - return generateDailyTimestampsFromRange(firstEventTimestamp, latestSettledTimestamp).map((timestamp) => - toSettledDayTimestamp(timestamp) + 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: { @@ -1746,6 +1866,7 @@ export function buildProtocolReturnHistorySeries(args: { ethPriceData?: Map timestamps: number[] selectedVaultKey?: string + selectedVaultKeys?: string[] }): HoldingsPnLSimpleHistoryPoint[] { const userAddress = lowerCaseAddress(args.userAddress) const effectiveEvents = buildEffectiveSimpleEvents(args.events, userAddress) @@ -1787,9 +1908,11 @@ export function buildProtocolReturnHistorySeries(args: { ppsData: args.ppsData, currentTimestamp: timestamp }) - const selectedVault = args.selectedVaultKey - ? vaults.find((vault) => toVaultKey(vault.chainId, vault.vaultAddress) === args.selectedVaultKey) - : null + 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, @@ -1816,12 +1939,12 @@ export function buildProtocolReturnHistorySeries(args: { protocolReturnPct: summary.protocolReturnPct, annualizedProtocolReturnPct: summary.annualizedProtocolReturnPct, growthIndex, - ...(args.selectedVaultKey + ...(selectedVaultKeys.length > 0 ? { - currentUnderlying: selectedVault?.currentUnderlying ?? 0, - growthUnderlying: selectedVault?.growthUnderlying ?? 0, - sharesFormatted: selectedVault?.sharesFormatted ?? 0, - pricePerShare: selectedVault?.pricePerShare ?? 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 } : {}) } @@ -1964,169 +2087,54 @@ export function buildProtocolReturnFamilyHistorySeries(args: { }) } -export async function getHoldingsPnLSimple( - userAddress: string, - version: VaultVersion = 'all', - fetchType: HoldingsEventFetchType = 'seq', - paginationMode: HoldingsEventPaginationMode = 'paged' -): Promise { - debugLog('pnl-simple', 'starting holdings simple pnl calculation', { version, fetchType, paginationMode }) - const rawContext = await fetchRawUserPnlEvents(userAddress, 'all', undefined, fetchType, paginationMode) - const rawEvents = buildRawPnlEvents(rawContext) - const rawVaultIdentifiers = getVaultIdentifiers(rawEvents) - const resolvedVaultMetadata = await fetchMultipleVaultsMetadata(rawVaultIdentifiers) - const effectiveEvents = buildEffectiveSimpleEvents( - filterEventsByAuthoritativeVersion(rawEvents, resolvedVaultMetadata, version), - userAddress - ) - const filteredVaultIdentifiers = getVaultIdentifiers(effectiveEvents) - const baseVaultMetadata = filteredVaultIdentifiers.reduce>((filtered, vault) => { - const key = toVaultKey(vault.chainId, vault.vaultAddress) - const metadata = resolvedVaultMetadata.get(key) - - if (metadata) { - filtered.set(key, metadata) - } - - return filtered - }, new Map()) - const vaultMetadata = await resolveNestedVaultAssetMetadata(baseVaultMetadata) - const currentTimestamp = Math.floor(Date.now() / 1000) - - debugLog('pnl-simple', 'loaded address-scoped events', { - version, - addressDeposits: rawContext.addressEvents.deposits.length, - addressWithdrawals: rawContext.addressEvents.withdrawals.length, - addressTransfersIn: rawContext.addressEvents.transfersIn.length, - addressTransfersOut: rawContext.addressEvents.transfersOut.length, - rawEvents: rawEvents.length, - effectiveEvents: effectiveEvents.length, - vaults: filteredVaultIdentifiers.length - }) - - if (effectiveEvents.length === 0 || filteredVaultIdentifiers.length === 0) { - return { - address: lowerCaseAddress(userAddress), - version, - generatedAt: new Date().toISOString(), - summary: buildSummary([]), - vaults: [] - } - } - - const baseReceiptPriceRequests = buildReceiptPriceRequests({ - events: effectiveEvents, - metadata: vaultMetadata, - userAddress, - currentTimestamp - }) - const receiptPriceRequests = expandNestedVaultAssetPriceRequests(baseReceiptPriceRequests, vaultMetadata) - const uniqueReceiptPriceTimestamps = new Set(receiptPriceRequests.flatMap((request) => request.timestamps)) - - debugLog('pnl-simple', 'prepared targeted receipt price requests', { - tokens: receiptPriceRequests.length, - uniqueTimestamps: uniqueReceiptPriceTimestamps.size, - pricePoints: countReceiptPricePoints(receiptPriceRequests), - bucketSeconds: RECEIPT_PRICE_BUCKET_SECONDS - }) - - const ppsIdentifiers = mergeVaultIdentifiers([ - ...filteredVaultIdentifiers, - ...getNestedVaultPpsIdentifiersFromPriceRequests(baseReceiptPriceRequests, vaultMetadata) - ]) - const [ppsData, fetchedPriceData] = await Promise.all([ - fetchMultipleVaultsPPS(ppsIdentifiers), - fetchReceiptPrices(receiptPriceRequests) - ]) - const priceData = deriveNestedVaultAssetPriceData({ - priceData: fetchedPriceData, - priceRequests: receiptPriceRequests, - vaultMetadata, - ppsData - }) - const ledgers = buildProtocolReturnLedgers({ - events: effectiveEvents, - userAddress, - metadata: vaultMetadata, - ppsData, - priceData, - currentTimestamp - }) - const vaults = materializeProtocolReturnVaults({ - ledgers, - metadata: vaultMetadata, - ppsData, - currentTimestamp - }).sort((a, b) => b.baselineWeightUsd - a.baselineWeightUsd) - const summary = buildSummary(vaults) - - debugLog('pnl-simple', 'completed holdings simple pnl calculation', { - version, - totalVaults: summary.totalVaults, - baselineWeightUsd: summary.baselineWeightUsd, - growthWeightUsd: summary.growthWeightUsd, - protocolReturnPct: summary.protocolReturnPct, - isComplete: summary.isComplete - }) - - return { - address: lowerCaseAddress(userAddress), - version, - generatedAt: new Date().toISOString(), - summary, - vaults - } -} - -export async function getHoldingsPnLSimpleHistory( +export async function getHoldingsProtocolReturnHistory( userAddress: string, version: VaultVersion = 'all', fetchType: HoldingsEventFetchType = 'seq', paginationMode: HoldingsEventPaginationMode = 'paged', timeframe: '1y' | 'all' = '1y', - vaultAddress?: string, - vaultChainId?: number + requestedVaultFilters?: Array<{ chainId: number; vaultAddress: string }> | string, + legacyVaultChainId?: number ): Promise { - debugLog('pnl-simple-history', 'starting holdings simple pnl history calculation', { + 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 requestedVault = - vaultAddress && Number.isInteger(vaultChainId) - ? { - chainId: Number(vaultChainId), - vaultAddress: lowerCaseAddress(vaultAddress) - } - : undefined - const rawContext = await fetchRawUserPnlEvents(userAddress, 'all', undefined, fetchType, paginationMode) - const rawEvents = buildRawPnlEvents(rawContext) - const rawVaultIdentifiers = getVaultIdentifiers(rawEvents) - const resolvedVaultMetadata = await fetchMultipleVaultsMetadata(rawVaultIdentifiers) - const versionFilteredEvents = filterEventsByAuthoritativeVersion(rawEvents, resolvedVaultMetadata, version) - const effectiveEvents = filterEventsByRequestedVault( - buildEffectiveSimpleEvents(versionFilteredEvents, userAddress), - requestedVault - ) - const filteredVaultIdentifiers = filterVaultIdentifiersByRequestedVault( + 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), - requestedVault + requestedVaults ) - const baseVaultMetadata = filteredVaultIdentifiers.reduce>((filtered, vault) => { - const key = toVaultKey(vault.chainId, vault.vaultAddress) - const metadata = resolvedVaultMetadata.get(key) - - if (metadata) { - filtered.set(key, metadata) - } - - return filtered - }, new Map()) - const vaultMetadata = await resolveNestedVaultAssetMetadata(baseVaultMetadata) + 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, @@ -2151,7 +2159,7 @@ export async function getHoldingsPnLSimpleHistory( } const timestamps = getProtocolReturnTimestamps(effectiveEvents, timeframe) - const latestTimestamp = timestamps[timestamps.length - 1] ?? Math.floor(Date.now() / 1000) + const latestTimestamp = timestamps[timestamps.length - 1] ?? settledContext.maxTimestamp const baseReceiptPriceRequests = buildReceiptPriceRequests({ events: effectiveEvents, metadata: vaultMetadata, @@ -2167,23 +2175,28 @@ export async function getHoldingsPnLSimpleHistory( ...filteredVaultIdentifiers, ...getNestedVaultPpsIdentifiersFromPriceRequests(baseReceiptPriceRequests, vaultMetadata) ]) - const [ppsData, fetchedPriceData, ethPriceData] = await Promise.all([ - fetchMultipleVaultsPPS(ppsIdentifiers), + 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 + ppsData: settledContext.ppsData }) const finalLedgers = buildProtocolReturnLedgers({ events: effectiveEvents, userAddress, metadata: vaultMetadata, - ppsData, + ppsData: settledContext.ppsData, priceData, ethPriceData, currentTimestamp: latestTimestamp @@ -2191,33 +2204,34 @@ export async function getHoldingsPnLSimpleHistory( const finalVaults = materializeProtocolReturnVaults({ ledgers: finalLedgers, metadata: vaultMetadata, - ppsData, + 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, + ppsData: settledContext.ppsData, priceData, ethPriceData, timestamps, - selectedVaultKey: - requestedVault && filteredVaultIdentifiers.length > 0 - ? toVaultKey(filteredVaultIdentifiers[0]!.chainId, filteredVaultIdentifiers[0]!.vaultAddress) - : undefined + selectedVaultKeys: requestedVaults + ? filteredVaultIdentifiers.map((vault) => toVaultKey(vault.chainId, vault.vaultAddress)) + : undefined }) const familySeries = buildProtocolReturnFamilyHistorySeries({ events: effectiveEvents, userAddress, metadata: vaultMetadata, - ppsData, + ppsData: settledContext.ppsData, priceData, ethPriceData, timestamps, - selectedVaults: requestedVault ? [] : selectedHistoryFamilies + selectedVaults: requestedVaults ? [] : selectedHistoryFamilies }) + reportHoldingsProgress(92, 'Built historical chart series', `${history.length} chart points`) const openBaselineCompositionUsd = buildOpenBaselineCompositionUsd({ ledgers: finalLedgers, metadata: vaultMetadata @@ -2225,11 +2239,17 @@ export async function getHoldingsPnLSimpleHistory( const { recommendedGrowthDisplay, recommendedGrowthDisplayReason } = resolveRecommendedGrowthDisplay(openBaselineCompositionUsd) - debugLog('pnl-simple-history', 'completed holdings simple pnl history calculation', { + 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 }) 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 index 3732e75c4..1f13eb5d1 100644 --- a/api/lib/holdings/services/pnlTypes.ts +++ b/api/lib/holdings/services/pnlTypes.ts @@ -1,81 +1,9 @@ -import type { VaultVersion } from './graphql' - -export type TLocation = 'vault' | 'staked' - export type TLot = { shares: bigint costBasis: bigint | null acquiredAt?: number } -export type TLotSummary = { - lotCount: number - totalShares: string - knownShares: string - unknownShares: string - totalKnownCostBasis: string -} - -export type TRealizedEntry = { - timestamp: number - proceedsAssets: bigint - basisAssets: bigint - pnlAssets: bigint - consumedLots: TLot[] -} - -export type TUnknownTransferInEntry = { - timestamp: number - shares: bigint - location: TLocation -} - -export type TRewardTransferInEntry = { - timestamp: number - shares: bigint - location: TLocation - distributor: string -} - -export type TUnknownWithdrawalEntry = { - timestamp: number - shares: bigint - proceedsAssets: bigint - consumedLots: TLot[] -} - -export type UnknownTransferInPnlMode = 'strict' | 'zero_basis' | 'windfall' - -export type TPnlDebugJournalRow = { - timestamp: number - txHash: string - familyVaultAddress: string - stakingVaultAddress: string | null - view: string - hasAddressActivity: boolean - rawEvents: string - depositShares: string - depositAssets: string - withdrawShares: string - withdrawAssets: string - stakeShares: string - unstakeShares: string - rewardInVaultShares: string - rewardInStakedShares: string - unknownInVaultShares: string - unknownInStakedShares: string - transferOutVaultShares: string - transferOutStakedShares: string - realizedKnownShares: string - realizedProceedsAssets: string - realizedBasisAssets: string - realizedPnlAssets: string - vaultLotsBefore: TLotSummary - stakedLotsBefore: TLotSummary - vaultLotsAfter: TLotSummary - stakedLotsAfter: TLotSummary -} - export type TRawScopes = { address: boolean tx: boolean @@ -134,267 +62,3 @@ export type TRawPnlEvent = shares: bigint scopes: TRawScopes } - -export interface FamilyPnlLedger { - chainId: number - vaultAddress: string - stakingVaultAddress: string | null - vaultLots: TLot[] - stakedLots: TLot[] - totalDepositedAssets: bigint - totalWithdrawnAssets: bigint - unknownCostBasisTransferInCount: number - unknownCostBasisTransferInShares: bigint - withdrawalsWithUnknownCostBasis: number - unmatchedTransferOutCount: number - unmatchedTransferOutShares: bigint - realizedEntries: TRealizedEntry[] - rewardTransferInEntries: TRewardTransferInEntry[] - unknownTransferInEntries: TUnknownTransferInEntry[] - unknownWithdrawalEntries: TUnknownWithdrawalEntry[] - debugJournal: TPnlDebugJournalRow[] - eventCounts: { - underlyingDeposits: number - underlyingWithdrawals: number - stakes: number - unstakes: number - rewardTransfersIn: number - externalTransfersIn: number - externalTransfersOut: number - migrationsIn: number - migrationsOut: number - } -} - -export interface HoldingsPnLVault { - chainId: number - vaultAddress: string - stakingVaultAddress: string | null - status: 'ok' | 'missing_metadata' | 'missing_price' | 'missing_pps' - costBasisStatus: 'complete' | 'partial' - unknownTransferInPnlMode: UnknownTransferInPnlMode - shares: string - sharesFormatted: number - vaultShares: string - vaultSharesFormatted: number - stakedShares: string - stakedSharesFormatted: number - knownCostBasisShares: string - unknownCostBasisShares: string - pricePerShare: number - tokenPrice: number - currentUnderlying: number - vaultUnderlying: number - stakedUnderlying: number - currentKnownUnderlying: number - currentUnknownUnderlying: number - knownCostBasisUnderlying: number - knownCostBasisUsd: number - currentValueUsd: number - vaultValueUsd: number - stakedValueUsd: number - unknownCostBasisValueUsd: number - windfallPnlUsd: number - realizedPnlUnderlying: number - realizedPnlUsd: number - unrealizedPnlUnderlying: number - unrealizedPnlUsd: number - totalPnlUsd: number - totalEconomicGainUsd: number - totalDepositedUnderlying: number - totalWithdrawnUnderlying: number - eventCounts: { - underlyingDeposits: number - underlyingWithdrawals: number - stakes: number - unstakes: number - rewardTransfersIn: number - externalTransfersIn: number - externalTransfersOut: number - migrationsIn: number - migrationsOut: number - unknownCostBasisTransfersIn: number - withdrawalsWithUnknownCostBasis: number - } - metadata: { - symbol: string - decimals: number - assetDecimals: number - tokenAddress: string - category: 'stable' | 'volatile' - } | null -} - -export interface HoldingsPnLCategoryBreakdown { - totalPnlUsd: number - totalEconomicGainUsd: number -} - -export type THoldingsPnlSummary = { - totalVaults: number - completeVaults: number - partialVaults: number - totalCurrentValueUsd: number - totalUnknownCostBasisValueUsd: number - totalWindfallPnlUsd: number - totalRealizedPnlUsd: number - totalUnrealizedPnlUsd: number - totalPnlUsd: number - totalEconomicGainUsd: number - byCategory: { - stable: HoldingsPnLCategoryBreakdown - volatile: HoldingsPnLCategoryBreakdown - } - isComplete: boolean -} - -export type THoldingsPnlLot = { - index: number - shares: string - sharesFormatted: number - costBasis: string | null - costBasisFormatted: number | null - acquiredAt: number | null - costBasisUsd: number | null - pricePerShareAtAcquisition: number - tokenPriceAtAcquisition: number - currentUnderlying: number - currentValueUsd: number -} - -export type THoldingsPnlRealizedEntry = { - timestamp: number - proceedsAssets: string - proceedsUnderlying: number - proceedsUsd: number - basisAssets: string - basisUnderlying: number - basisUsd: number - pnlAssets: string - pnlUnderlying: number - pnlUsd: number - consumedLots: THoldingsPnlLot[] -} - -export type THoldingsPnlUnknownTransferInEntry = { - timestamp: number - location: TLocation - shares: string - sharesFormatted: number - pricePerShareAtReceipt: number - tokenPriceAtReceipt: number - receiptUnderlying: number - receiptValueUsd: number -} - -export type THoldingsPnlRewardTransferInEntry = { - timestamp: number - location: TLocation - distributor: string - shares: string - sharesFormatted: number - pricePerShareAtReceipt: number - tokenPriceAtReceipt: number - receiptUnderlying: number - receiptValueUsd: number -} - -export type THoldingsPnlUnknownWithdrawalEntry = { - timestamp: number - shares: string - sharesFormatted: number - proceedsAssets: string - proceedsUnderlying: number - proceedsUsd: number - consumedLots: THoldingsPnlLot[] -} - -export type THoldingsPnlJournalLotSummary = TLotSummary & { - totalSharesFormatted: number - knownSharesFormatted: number - unknownSharesFormatted: number - totalKnownCostBasisFormatted: number -} - -export type THoldingsPnlJournalEntry = { - timestamp: number - txHash: string - view: string - hasAddressActivity: boolean - rawEvents: string - depositShares: string - depositSharesFormatted: number - depositAssets: string - depositAssetsFormatted: number - withdrawShares: string - withdrawSharesFormatted: number - withdrawAssets: string - withdrawAssetsFormatted: number - stakeShares: string - stakeSharesFormatted: number - unstakeShares: string - unstakeSharesFormatted: number - rewardInVaultShares: string - rewardInVaultSharesFormatted: number - rewardInStakedShares: string - rewardInStakedSharesFormatted: number - unknownInVaultShares: string - unknownInVaultSharesFormatted: number - unknownInStakedShares: string - unknownInStakedSharesFormatted: number - transferOutVaultShares: string - transferOutVaultSharesFormatted: number - transferOutStakedShares: string - transferOutStakedSharesFormatted: number - realizedKnownShares: string - realizedKnownSharesFormatted: number - realizedProceedsAssets: string - realizedProceedsAssetsFormatted: number - realizedBasisAssets: string - realizedBasisAssetsFormatted: number - realizedPnlAssets: string - realizedPnlAssetsFormatted: number - vaultLotsBefore: THoldingsPnlJournalLotSummary - stakedLotsBefore: THoldingsPnlJournalLotSummary - vaultLotsAfter: THoldingsPnlJournalLotSummary - stakedLotsAfter: THoldingsPnlJournalLotSummary -} - -export interface HoldingsPnLDrilldownVault extends HoldingsPnLVault { - currentLots: { - vault: THoldingsPnlLot[] - staked: THoldingsPnlLot[] - } - realizedEntries: THoldingsPnlRealizedEntry[] - rewardTransferInEntries: THoldingsPnlRewardTransferInEntry[] - unknownTransferInEntries: THoldingsPnlUnknownTransferInEntry[] - unknownWithdrawalEntries: THoldingsPnlUnknownWithdrawalEntry[] - journal: THoldingsPnlJournalEntry[] -} - -export interface HoldingsPnLResponse { - address: string - version: VaultVersion - unknownTransferInPnlMode: UnknownTransferInPnlMode - generatedAt: string - summary: THoldingsPnlSummary - vaults: HoldingsPnLVault[] -} - -export interface HoldingsPnLDrilldownResponse { - address: string - version: VaultVersion - unknownTransferInPnlMode: UnknownTransferInPnlMode - generatedAt: string - summary: THoldingsPnlSummary - vaults: HoldingsPnLDrilldownVault[] -} - -export type TPnlComputationState = { - unknownCostBasisValueUsd: number - windfallPnlUsd: number - realizedPnlUnderlying: number - realizedPnlUsd: number - unrealizedPnlUnderlying: number - unrealizedPnlUsd: number -} diff --git a/api/lib/holdings/services/pnlValuation.ts b/api/lib/holdings/services/pnlValuation.ts deleted file mode 100644 index af9f700c3..000000000 --- a/api/lib/holdings/services/pnlValuation.ts +++ /dev/null @@ -1,319 +0,0 @@ -import type { VaultMetadata } from '../types' -import { getPriceAtTimestamp } from './defillama' -import type { VaultVersion } from './graphql' -import { getPPS } from './kong' -import { formatAmount, sumShares } from './pnlShared' -import type { - FamilyPnlLedger, - HoldingsPnLCategoryBreakdown, - HoldingsPnLResponse, - HoldingsPnLVault, - TLot, - TPnlComputationState, - UnknownTransferInPnlMode -} from './pnlTypes' - -function createEmptyCategoryBreakdown(): HoldingsPnLCategoryBreakdown { - return { - totalPnlUsd: 0, - totalEconomicGainUsd: 0 - } -} - -export function toHoldingsPnlEventCounts(vault: FamilyPnlLedger): HoldingsPnLVault['eventCounts'] { - return { - underlyingDeposits: vault.eventCounts.underlyingDeposits, - underlyingWithdrawals: vault.eventCounts.underlyingWithdrawals, - stakes: vault.eventCounts.stakes, - unstakes: vault.eventCounts.unstakes, - rewardTransfersIn: vault.eventCounts.rewardTransfersIn, - externalTransfersIn: vault.eventCounts.externalTransfersIn, - externalTransfersOut: vault.eventCounts.externalTransfersOut, - migrationsIn: vault.eventCounts.migrationsIn, - migrationsOut: vault.eventCounts.migrationsOut, - unknownCostBasisTransfersIn: vault.unknownCostBasisTransferInCount, - withdrawalsWithUnknownCostBasis: vault.withdrawalsWithUnknownCostBasis - } -} - -export function createEmptyHoldingsPnlResponse( - address: string, - version: VaultVersion, - unknownTransferInPnlMode: UnknownTransferInPnlMode -): HoldingsPnLResponse { - return { - address, - version, - unknownTransferInPnlMode, - generatedAt: new Date().toISOString(), - summary: { - totalVaults: 0, - completeVaults: 0, - partialVaults: 0, - totalCurrentValueUsd: 0, - totalUnknownCostBasisValueUsd: 0, - totalWindfallPnlUsd: 0, - totalRealizedPnlUsd: 0, - totalUnrealizedPnlUsd: 0, - totalPnlUsd: 0, - totalEconomicGainUsd: 0, - byCategory: { - stable: createEmptyCategoryBreakdown(), - volatile: createEmptyCategoryBreakdown() - }, - isComplete: true - }, - vaults: [] - } -} - -export function summarizePnlVaults(vaults: HoldingsPnLVault[]): HoldingsPnLResponse['summary'] { - return vaults.reduce( - (totals, vault) => { - const category = vault.metadata?.category ?? 'volatile' - - return { - totalVaults: totals.totalVaults + 1, - completeVaults: totals.completeVaults + (vault.costBasisStatus === 'complete' ? 1 : 0), - partialVaults: totals.partialVaults + (vault.costBasisStatus === 'partial' ? 1 : 0), - totalCurrentValueUsd: totals.totalCurrentValueUsd + vault.currentValueUsd, - totalUnknownCostBasisValueUsd: totals.totalUnknownCostBasisValueUsd + vault.unknownCostBasisValueUsd, - totalWindfallPnlUsd: totals.totalWindfallPnlUsd + vault.windfallPnlUsd, - totalRealizedPnlUsd: totals.totalRealizedPnlUsd + vault.realizedPnlUsd, - totalUnrealizedPnlUsd: totals.totalUnrealizedPnlUsd + vault.unrealizedPnlUsd, - totalPnlUsd: totals.totalPnlUsd + vault.totalPnlUsd, - totalEconomicGainUsd: totals.totalEconomicGainUsd + vault.totalEconomicGainUsd, - byCategory: { - stable: { - totalPnlUsd: totals.byCategory.stable.totalPnlUsd + (category === 'stable' ? vault.totalPnlUsd : 0), - totalEconomicGainUsd: - totals.byCategory.stable.totalEconomicGainUsd + (category === 'stable' ? vault.totalEconomicGainUsd : 0) - }, - volatile: { - totalPnlUsd: totals.byCategory.volatile.totalPnlUsd + (category === 'volatile' ? vault.totalPnlUsd : 0), - totalEconomicGainUsd: - totals.byCategory.volatile.totalEconomicGainUsd + - (category === 'volatile' ? vault.totalEconomicGainUsd : 0) - } - }, - isComplete: totals.isComplete && vault.costBasisStatus === 'complete' - } - }, - { - totalVaults: 0, - completeVaults: 0, - partialVaults: 0, - totalCurrentValueUsd: 0, - totalUnknownCostBasisValueUsd: 0, - totalWindfallPnlUsd: 0, - totalRealizedPnlUsd: 0, - totalUnrealizedPnlUsd: 0, - totalPnlUsd: 0, - totalEconomicGainUsd: 0, - byCategory: { - stable: createEmptyCategoryBreakdown(), - volatile: createEmptyCategoryBreakdown() - }, - isComplete: true - } - ) -} - -function getEstimatedUnknownUnderlyingAtReceipt( - shares: bigint, - timestamp: number, - shareDecimals: number, - ppsMap: Map | undefined, - fallbackPricePerShare: number -): number { - const receiptPricePerShare = - timestamp > 0 - ? ppsMap - ? (getPPS(ppsMap, timestamp) ?? fallbackPricePerShare) - : fallbackPricePerShare - : fallbackPricePerShare - - return formatAmount(shares, shareDecimals) * receiptPricePerShare -} - -function getTokenPriceForTimestamp( - tokenPriceMap: Map | null, - timestamp: number, - fallbackTokenPrice: number -): number { - return timestamp > 0 - ? tokenPriceMap - ? getPriceAtTimestamp(tokenPriceMap, timestamp) - : fallbackTokenPrice - : fallbackTokenPrice -} - -// Missing metadata is a hard stop for valuation, but we still surface the ledger so the caller -// can see that shares exist and that the accounting path reached this family. -export function createMissingMetadataPnlVault( - vault: FamilyPnlLedger, - unknownTransferInPnlMode: UnknownTransferInPnlMode, - tokenPrice: number, - resolvedPricePerShare: number -): HoldingsPnLVault { - const vaultSharesRaw = sumShares(vault.vaultLots) - const stakedSharesRaw = sumShares(vault.stakedLots) - const totalSharesRaw = vaultSharesRaw + stakedSharesRaw - const knownLots = [...vault.vaultLots, ...vault.stakedLots].filter((lot) => lot.costBasis !== null) - const unknownLots = [...vault.vaultLots, ...vault.stakedLots].filter((lot) => lot.costBasis === null) - - return { - chainId: vault.chainId, - vaultAddress: vault.vaultAddress, - stakingVaultAddress: vault.stakingVaultAddress, - status: 'missing_metadata', - costBasisStatus: 'partial', - unknownTransferInPnlMode, - shares: totalSharesRaw.toString(), - sharesFormatted: 0, - vaultShares: vaultSharesRaw.toString(), - vaultSharesFormatted: 0, - stakedShares: stakedSharesRaw.toString(), - stakedSharesFormatted: 0, - knownCostBasisShares: sumShares(knownLots).toString(), - unknownCostBasisShares: sumShares(unknownLots).toString(), - pricePerShare: resolvedPricePerShare, - tokenPrice, - currentUnderlying: 0, - vaultUnderlying: 0, - stakedUnderlying: 0, - currentKnownUnderlying: 0, - currentUnknownUnderlying: 0, - knownCostBasisUnderlying: 0, - knownCostBasisUsd: 0, - currentValueUsd: 0, - vaultValueUsd: 0, - stakedValueUsd: 0, - unknownCostBasisValueUsd: 0, - windfallPnlUsd: 0, - realizedPnlUnderlying: 0, - realizedPnlUsd: 0, - unrealizedPnlUnderlying: 0, - unrealizedPnlUsd: 0, - totalPnlUsd: 0, - totalEconomicGainUsd: 0, - totalDepositedUnderlying: 0, - totalWithdrawnUnderlying: 0, - eventCounts: toHoldingsPnlEventCounts(vault), - metadata: null - } -} - -// Unknown transfer-ins are the main heuristic branch in the whole pipeline. -// We keep the strict / zero-basis / windfall behavior isolated here so the materialization -// step reads as "compute known PnL, then apply the chosen unknown-lot policy". -export function applyUnknownTransferInModeAdjustment(args: { - unknownTransferInPnlMode: UnknownTransferInPnlMode - vault: FamilyPnlLedger - unknownLots: TLot[] - metadata: VaultMetadata - ppsMap: Map | undefined - tokenPriceMap: Map | null - tokenPrice: number - currentTimestamp: number - resolvedPricePerShare: number - currentUnknownUnderlying: number - baseRealizedPnlUnderlying: number - baseRealizedPnlUsd: number - baseUnrealizedPnlUnderlying: number - baseUnrealizedPnlUsd: number -}): TPnlComputationState { - const { - unknownTransferInPnlMode, - vault, - unknownLots, - metadata, - ppsMap, - tokenPriceMap, - tokenPrice, - currentTimestamp, - resolvedPricePerShare, - currentUnknownUnderlying, - baseRealizedPnlUnderlying, - baseRealizedPnlUsd, - baseUnrealizedPnlUnderlying, - baseUnrealizedPnlUsd - } = args - - const state: TPnlComputationState = { - unknownCostBasisValueUsd: unknownTransferInPnlMode === 'strict' ? currentUnknownUnderlying * tokenPrice : 0, - windfallPnlUsd: 0, - realizedPnlUnderlying: baseRealizedPnlUnderlying, - realizedPnlUsd: baseRealizedPnlUsd, - unrealizedPnlUnderlying: baseUnrealizedPnlUnderlying, - unrealizedPnlUsd: baseUnrealizedPnlUsd - } - - if (unknownTransferInPnlMode === 'strict') { - return state - } - - const unknownRealizedProceedsUnderlying = vault.unknownWithdrawalEntries.reduce( - (total, entry) => total + formatAmount(entry.proceedsAssets, metadata.token.decimals), - 0 - ) - const unknownRealizedProceedsUsd = vault.unknownWithdrawalEntries.reduce((total, entry) => { - const realizedTokenPrice = getTokenPriceForTimestamp(tokenPriceMap, entry.timestamp, tokenPrice) - return total + formatAmount(entry.proceedsAssets, metadata.token.decimals) * realizedTokenPrice - }, 0) - - if (unknownTransferInPnlMode === 'zero_basis') { - return { - ...state, - realizedPnlUnderlying: state.realizedPnlUnderlying + unknownRealizedProceedsUnderlying, - realizedPnlUsd: state.realizedPnlUsd + unknownRealizedProceedsUsd, - unrealizedPnlUnderlying: state.unrealizedPnlUnderlying + currentUnknownUnderlying, - unrealizedPnlUsd: state.unrealizedPnlUsd + currentUnknownUnderlying * tokenPrice - } - } - - const currentUnknownReceiptValueUsd = unknownLots.reduce((total, lot) => { - const receiptUnderlying = getEstimatedUnknownUnderlyingAtReceipt( - lot.shares, - lot.acquiredAt ?? currentTimestamp, - metadata.decimals, - ppsMap, - resolvedPricePerShare - ) - const receiptTokenPrice = getTokenPriceForTimestamp(tokenPriceMap, lot.acquiredAt ?? currentTimestamp, tokenPrice) - return total + receiptUnderlying * receiptTokenPrice - }, 0) - const realizedUnknownReceiptValueUsd = vault.unknownWithdrawalEntries.reduce((total, entry) => { - return ( - total + - entry.consumedLots.reduce((entryTotal, lot) => { - const receiptUnderlying = getEstimatedUnknownUnderlyingAtReceipt( - lot.shares, - lot.acquiredAt ?? entry.timestamp, - metadata.decimals, - ppsMap, - resolvedPricePerShare - ) - const receiptTokenPrice = getTokenPriceForTimestamp( - tokenPriceMap, - lot.acquiredAt ?? entry.timestamp, - tokenPrice - ) - return entryTotal + receiptUnderlying * receiptTokenPrice - }, 0) - ) - }, 0) - const unknownRealizedMarketPnlUsd = unknownRealizedProceedsUsd - realizedUnknownReceiptValueUsd - const unknownCurrentMarketPnlUsd = currentUnknownUnderlying * tokenPrice - currentUnknownReceiptValueUsd - - return { - ...state, - windfallPnlUsd: currentUnknownReceiptValueUsd + realizedUnknownReceiptValueUsd, - realizedPnlUnderlying: - state.realizedPnlUnderlying + (tokenPrice > 0 ? unknownRealizedMarketPnlUsd / tokenPrice : 0), - realizedPnlUsd: state.realizedPnlUsd + unknownRealizedMarketPnlUsd, - unrealizedPnlUnderlying: - state.unrealizedPnlUnderlying + (tokenPrice > 0 ? unknownCurrentMarketPnlUsd / tokenPrice : 0), - unrealizedPnlUsd: state.unrealizedPnlUsd + unknownCurrentMarketPnlUsd - } -} diff --git a/api/lib/holdings/services/progress.ts b/api/lib/holdings/services/progress.ts new file mode 100644 index 000000000..71517332c --- /dev/null +++ b/api/lib/holdings/services/progress.ts @@ -0,0 +1,295 @@ +import { createHash } from 'node:crypto' +import { getPool, isDatabaseEnabled } from '../db/connection' + +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[] +} + +type HoldingsProgressRow = { + id: string + route: string + address_hash: string + status: HoldingsProgressStatus + progress: number + message: string + detail: string | null + started_at: Date | string + updated_at: Date | string + logs: unknown +} + +const PROGRESS_TTL_INTERVAL = '10 minutes' +const PERSISTED_PROGRESS_CLEANUP_INTERVAL_MS = 60 * 1000 +const MAX_PROGRESS_LOGS = 20 +const persistedProgressCleanupState = { lastCleanupAt: 0 } + +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 clampProgress(progress: number): number { + if (!Number.isFinite(progress)) { + return 0 + } + return Math.max(0, Math.min(100, Math.round(progress))) +} + +async function cleanupPersistedProgressRecords(): Promise { + const now = Date.now() + if (now - persistedProgressCleanupState.lastCleanupAt < PERSISTED_PROGRESS_CLEANUP_INTERVAL_MS) { + return + } + persistedProgressCleanupState.lastCleanupAt = now + + if (!isDatabaseEnabled()) { + return + } + + const pool = await getPool() + if (!pool) { + return + } + + try { + await pool.query(`DELETE FROM holdings_progress WHERE updated_at < NOW() - INTERVAL '${PROGRESS_TTL_INTERVAL}'`) + } catch (error) { + console.error('[Holdings Progress] Failed to delete stale progress rows:', error) + } +} + +function parseTimestamp(value: Date | string): number { + return value instanceof Date ? value.getTime() : new Date(value).getTime() +} + +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' + ) + }) + } + + if (typeof value === 'string') { + try { + return parseLogs(JSON.parse(value)) + } catch { + return [] + } + } + + return [] +} + +function rowToProgressRecord(row: HoldingsProgressRow): HoldingsProgressRecord { + return { + id: row.id, + route: row.route, + addressHash: row.address_hash, + status: row.status, + progress: clampProgress(Number(row.progress)), + message: row.message, + detail: row.detail ?? null, + startedAt: parseTimestamp(row.started_at), + updatedAt: parseTimestamp(row.updated_at), + logs: parseLogs(row.logs).slice(-MAX_PROGRESS_LOGS) + } +} + +async function persistProgressRecord(record: HoldingsProgressRecord): Promise { + if (!isDatabaseEnabled()) { + return false + } + + const pool = await getPool() + if (!pool) { + return false + } + + try { + await pool.query( + `INSERT INTO holdings_progress ( + id, route, address_hash, status, progress, message, detail, started_at, updated_at, logs + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10::jsonb) + ON CONFLICT (id) + DO UPDATE SET + route = EXCLUDED.route, + address_hash = EXCLUDED.address_hash, + status = EXCLUDED.status, + progress = CASE + WHEN EXCLUDED.status = 'complete' THEN 100 + ELSE GREATEST(holdings_progress.progress, EXCLUDED.progress) + END, + message = EXCLUDED.message, + detail = EXCLUDED.detail, + updated_at = EXCLUDED.updated_at, + logs = EXCLUDED.logs + WHERE holdings_progress.updated_at <= EXCLUDED.updated_at`, + [ + record.id, + record.route, + record.addressHash, + record.status, + record.progress, + record.message, + record.detail, + new Date(record.startedAt), + new Date(record.updatedAt), + JSON.stringify(record.logs) + ] + ) + return true + } catch (error) { + console.error('[Holdings Progress] Failed to save progress row:', error) + return false + } +} + +async function getPersistedProgressRecord(id: string): Promise { + if (!isDatabaseEnabled()) { + return null + } + + const pool = await getPool() + if (!pool) { + return null + } + + try { + const result = await pool.query( + `SELECT id, route, address_hash, status, progress, message, detail, started_at, updated_at, logs + FROM holdings_progress + WHERE id = $1 AND updated_at >= NOW() - INTERVAL '${PROGRESS_TTL_INTERVAL}'`, + [id] + ) + const row = result.rows[0] + return row ? rowToProgressRecord(row) : null + } catch (error) { + console.error('[Holdings Progress] Failed to get progress row:', error) + return null + } +} + +export async function startHoldingsProgress({ + id, + route, + address, + message +}: { + id: string | null | undefined + route: string + address: string + message: string +}): Promise { + void cleanupPersistedProgressRecords() + + if (!isValidProgressId(id) || !isDatabaseEnabled()) { + 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 { + void cleanupPersistedProgressRecords() + + if (!isValidProgressId(id)) { + return null + } + + return getPersistedProgressRecord(id) +} diff --git a/api/lib/holdings/services/ratelimit.ts b/api/lib/holdings/services/ratelimit.ts index 3544064bb..4091db0f0 100644 --- a/api/lib/holdings/services/ratelimit.ts +++ b/api/lib/holdings/services/ratelimit.ts @@ -1,13 +1,30 @@ import { getPool, isDatabaseEnabled } from '../db/connection' const WINDOW_MS = 60 * 1000 // 1 minute +const WINDOW_INTERVAL = '1 minute' const MAX_REQUESTS = 10 +const RATE_LIMIT_CLEANUP_INTERVAL_MS = 60 * 1000 +const rateLimitCleanupState = { lastCleanupAt: 0 } export interface RateLimitResult { allowed: boolean retryAfter?: number } +async function cleanupStaleRateLimitRows(pool: NonNullable>>): Promise { + const now = Date.now() + if (now - rateLimitCleanupState.lastCleanupAt < RATE_LIMIT_CLEANUP_INTERVAL_MS) { + return + } + rateLimitCleanupState.lastCleanupAt = now + + try { + await pool.query(`DELETE FROM rate_limits WHERE window_start < NOW() - INTERVAL '${WINDOW_INTERVAL}'`) + } catch (error) { + console.error('[RateLimit] Failed to delete stale rows:', error) + } +} + export async function checkRateLimit(ip: string): Promise { if (!isDatabaseEnabled()) { return { allowed: true } @@ -24,12 +41,12 @@ export async function checkRateLimit(ip: string): Promise { VALUES ($1, 1, NOW()) ON CONFLICT (ip) DO UPDATE SET request_count = CASE - WHEN rate_limits.window_start < NOW() - INTERVAL '1 minute' + WHEN rate_limits.window_start < NOW() - INTERVAL '${WINDOW_INTERVAL}' THEN 1 ELSE rate_limits.request_count + 1 END, window_start = CASE - WHEN rate_limits.window_start < NOW() - INTERVAL '1 minute' + WHEN rate_limits.window_start < NOW() - INTERVAL '${WINDOW_INTERVAL}' THEN NOW() ELSE rate_limits.window_start END @@ -38,6 +55,7 @@ export async function checkRateLimit(ip: string): Promise { ) const { request_count, window_start } = result.rows[0] + void cleanupStaleRateLimitRows(pool) if (request_count > MAX_REQUESTS) { const windowEnd = new Date(window_start).getTime() + WINDOW_MS 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/vaults.ts b/api/lib/holdings/services/vaults.ts index 8006039d0..1c00588fe 100644 --- a/api/lib/holdings/services/vaults.ts +++ b/api/lib/holdings/services/vaults.ts @@ -1,4 +1,4 @@ -import { config } from '../config' +import { holdingsConfig } from '../config' import type { VaultMetadata } from '../types' import { debugError, debugLog } from './debug' import { getUnderlyingVault, isStakingVault } from './staking' @@ -308,7 +308,7 @@ function storeStakingMetadata(key: string, metadata: VaultMetadata): void { } async function fetchVaultList(attempt = 0): Promise { - const url = `${config.kongBaseUrl}/api/rest/list/vaults?origin=yearn` + const url = `${holdingsConfig.kongBaseUrl}/api/rest/list/vaults?origin=yearn` debugLog('vaults', 'fetching global vault list', { attempt: attempt + 1, url }) try { @@ -335,7 +335,7 @@ async function fetchVaultList(attempt = 0): Promise { } async function fetchVaultSnapshot(chainId: number, vaultAddress: string, attempt = 0): Promise { - const url = `${config.kongBaseUrl}/api/rest/snapshot/${chainId}/${vaultAddress}` + const url = `${holdingsConfig.kongBaseUrl}/api/rest/snapshot/${chainId}/${vaultAddress}` debugLog('vaults', 'fetching vault snapshot fallback', { attempt: attempt + 1, chainId, diff --git a/api/server.ts b/api/server.ts index a6beef934..ac8966550 100644 --- a/api/server.ts +++ b/api/server.ts @@ -8,21 +8,18 @@ import type { import { ENSO_BALANCES_CACHE_CONTROL } from './enso/cache' import { clearUserCache, - deleteStaleCache, getHistoricalHoldingsChart, getHoldingsActivity, getHoldingsBreakdown, - getHoldingsPnL, - getHoldingsPnLDrilldown, - getHoldingsPnLSimple, - getHoldingsPnLSimpleHistory, + getHoldingsProtocolReturnHistory, + getHoldingsTotalsCacheVersion, + type HoldingsActivityTypeFilter, type HoldingsEventFetchType, type HoldingsEventPaginationMode, type HoldingsHistoryDenomination, type HoldingsHistoryTimeframe, initializeSchema, isDatabaseEnabled, - type UnknownTransferInPnlMode, type VaultVersion, validateConfig } from './lib/holdings' @@ -34,6 +31,8 @@ import { isHoldingsDebugRequested, withHoldingsDebugContext } from './lib/holdings/services/debug' +import { fetchRecentAddressScopedActivityEvents } from './lib/holdings/services/graphql' +import { getHoldingsProgress, startHoldingsProgress, updateHoldingsProgress } from './lib/holdings/services/progress' import { buildTenderlyPanelStatus, buildTenderlyRevertResponse, @@ -141,10 +140,69 @@ function handleCorsPreFlight(): Response { }) } +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 }> } @@ -164,10 +222,6 @@ function validateInvalidateBody(body: unknown): body is InvalidateRequestBody { return true } -function parseUnknownTransferInPnlMode(value: string | null): UnknownTransferInPnlMode { - return value === 'strict' || value === 'zero_basis' || value === 'windfall' ? value : 'windfall' -} - function parseHoldingsEventFetchType(value: string | null): HoldingsEventFetchType { return value === 'parallel' ? 'parallel' : 'seq' } @@ -204,6 +258,49 @@ function parseHoldingsActivityOffset(value: string | null): number { 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 @@ -504,12 +601,14 @@ async function handleHoldingsHistory(req: Request): Promise { 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) { @@ -520,11 +619,27 @@ async function handleHoldingsHistory(req: Request): Promise { 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: 'history', + address, + message: 'Fetching historical user data' + }) + await updateHoldingsProgress(activeProgressId, { + progress: 8, + message: 'Fetching historical user data', + detail: null + }) + if (refresh) { - const cleared = await clearUserCache(address, version) + const cleared = await clearUserCache(address, getHoldingsTotalsCacheVersion(version)) console.log(`[Server] Cleared ${cleared} cached entries for ${address}`) } @@ -532,7 +647,8 @@ async function handleHoldingsHistory(req: Request): Promise { createHoldingsDebugContext('history', address, debugEnabled, { lotsEnabled: debugLotsEnabled, vaultFilter: debugVault, - txFilter: debugTx + txFilter: debugTx, + progressId: activeProgressId }), async () => { debugLog('route', 'started holdings history request', { @@ -552,7 +668,8 @@ async function handleHoldingsHistory(req: Request): Promise { fetchType, paginationMode, denomination, - timeframe + timeframe, + vaultFilters ) debugLog('route', 'completed holdings history request', { version, @@ -574,9 +691,22 @@ async function handleHoldingsHistory(req: Request): Promise { const hasHoldings = holdings.dataPoints.some((dp) => dp.value > 0) if (!hasHoldings) { + 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 }) } + await updateHoldingsProgress(activeProgressId, { + status: 'complete', + progress: 100, + message: 'Historical user data ready', + detail: `${holdings.dataPoints.length} chart points` + }) + return Response.json( { address: holdings.address, @@ -595,6 +725,11 @@ async function handleHoldingsHistory(req: Request): Promise { } ) } 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 @@ -608,6 +743,11 @@ async function handleHoldingsActivity(req: Request): Promise { 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 }) @@ -620,7 +760,19 @@ async function handleHoldingsActivity(req: Request): Promise { const version: VaultVersion = versionParam === 'v2' || versionParam === 'v3' ? versionParam : 'all' try { - const activity = await getHoldingsActivity(address, version, limit, offset) + const activity = await getHoldingsActivity( + address, + version, + limit, + offset, + { + type, + chainId, + startTimestamp, + endTimestamp + }, + includeFacets + ) return Response.json(activity, { headers: { @@ -635,6 +787,68 @@ async function handleHoldingsActivity(req: Request): Promise { } } +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 (!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' + } + } + ) + } 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 } + ) + } +} + async function handleHoldingsBreakdown(req: Request): Promise { const url = new URL(req.url) const address = url.searchParams.get('address') @@ -725,18 +939,20 @@ async function handleHoldingsBreakdown(req: Request): Promise { } } -async function handleHoldingsPnL(req: Request): Promise { +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 unknownTransferInPnlMode = parseUnknownTransferInPnlMode(url.searchParams.get('unknownMode')) 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 }) @@ -746,193 +962,37 @@ async function handleHoldingsPnL(req: Request): Promise { return Response.json({ error: 'Invalid Ethereum address', status: 400 }, { status: 400 }) } - const version: VaultVersion = versionParam === 'v2' || versionParam === 'v3' ? versionParam : 'all' - - try { - const pnl = await withHoldingsDebugContext( - createHoldingsDebugContext('pnl', address, debugEnabled, { - lotsEnabled: debugLotsEnabled, - vaultFilter: debugVault, - txFilter: debugTx - }), - async () => { - debugLog('route', 'started holdings pnl request', { - version, - unknownTransferInPnlMode, - fetchType, - paginationMode, - debugLotsEnabled, - debugVault: debugVault?.toLowerCase() ?? null, - debugTx: debugTx?.toLowerCase() ?? null - }) - - try { - const response = await getHoldingsPnL(address, version, unknownTransferInPnlMode, fetchType, paginationMode) - debugLog('route', 'completed holdings pnl request', { - version, - unknownTransferInPnlMode, - fetchType, - paginationMode, - totalVaults: response.summary.totalVaults, - totalCurrentValueUsd: response.summary.totalCurrentValueUsd, - totalPnlUsd: response.summary.totalPnlUsd, - totalEconomicGainUsd: response.summary.totalEconomicGainUsd - }) - return response - } catch (error) { - debugError('route', 'holdings pnl request failed', error, { - version, - unknownTransferInPnlMode, - fetchType, - paginationMode - }) - throw error - } - } - ) - - if (pnl.summary.totalVaults === 0) { - return Response.json({ error: 'No holdings found for address', status: 404 }, { status: 404 }) - } - - return Response.json(pnl, { - headers: { - 'Cache-Control': 'public, s-maxage=300, stale-while-revalidate=600' - } - }) - } catch (error) { - console.error('Error fetching holdings PnL:', 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 PnL', message, stack, status: 502 }, { status: 502 }) - } -} - -async function handleHoldingsPnLSimple(req: Request): Promise { - const url = new URL(req.url) - const address = url.searchParams.get('address') - const versionParam = url.searchParams.get('version') - 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')) - - 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 pnl = await withHoldingsDebugContext( - createHoldingsDebugContext('pnl-simple', address, debugEnabled, { - lotsEnabled: debugLotsEnabled, - vaultFilter: debugVault, - txFilter: debugTx - }), - async () => { - debugLog('route', 'started holdings simple pnl request', { - version, - fetchType, - paginationMode, - debugLotsEnabled, - debugVault: debugVault?.toLowerCase() ?? null, - debugTx: debugTx?.toLowerCase() ?? null - }) - - try { - const response = await getHoldingsPnLSimple(address, version, fetchType, paginationMode) - debugLog('route', 'completed holdings simple pnl request', { - version, - fetchType, - paginationMode, - totalVaults: response.summary.totalVaults, - baselineWeightUsd: response.summary.baselineWeightUsd, - growthWeightUsd: response.summary.growthWeightUsd, - protocolReturnPct: response.summary.protocolReturnPct - }) - return response - } catch (error) { - debugError('route', 'holdings simple pnl request failed', error, { - version, - fetchType, - paginationMode - }) - throw error - } - } - ) - - if (pnl.summary.totalVaults === 0) { - return Response.json({ error: 'No holdings found for address', status: 404 }, { status: 404 }) - } - - return Response.json(pnl, { - headers: { - 'Cache-Control': 'public, s-maxage=300, stale-while-revalidate=600' - } + 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 }) - } catch (error) { - console.error('Error fetching holdings simple PnL:', 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 simple PnL', message, stack, status: 502 }, { status: 502 }) - } -} - -async function handleHoldingsPnLSimpleHistory(req: Request): Promise { - const url = new URL(req.url) - const address = url.searchParams.get('address') - const chainIdParam = url.searchParams.get('chainId') - const versionParam = url.searchParams.get('version') - const vault = url.searchParams.get('vault') - 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')) - - 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 (vault !== null && !isValidAddress(vault)) { - return Response.json({ error: 'Invalid vault address', status: 400 }, { status: 400 }) - } - - if (vault !== null && (!chainIdParam || !Number.isInteger(Number(chainIdParam)))) { - return Response.json({ error: 'Missing or invalid chainId for vault filter', status: 400 }, { status: 400 }) - } - - const version: VaultVersion = versionParam === 'v2' || versionParam === 'v3' ? versionParam : 'all' - try { const history = await withHoldingsDebugContext( - createHoldingsDebugContext('pnl-simple-history', address, debugEnabled, { + createHoldingsDebugContext('protocol-return-history', address, debugEnabled, { lotsEnabled: debugLotsEnabled, vaultFilter: debugVault, - txFilter: debugTx + txFilter: debugTx, + progressId: activeProgressId }), async () => { - debugLog('route', 'started holdings simple pnl history request', { + debugLog('route', 'started holdings protocol return history request', { version, timeframe, - vault: vault?.toLowerCase() ?? null, - chainId: vault !== null ? Number(chainIdParam) : null, + vaults: vaultFilters?.map((vault) => `${vault.chainId}:${vault.vaultAddress.toLowerCase()}`) ?? null, fetchType, paginationMode, debugLotsEnabled, @@ -941,20 +1001,18 @@ async function handleHoldingsPnLSimpleHistory(req: Request): Promise { }) try { - const response = await getHoldingsPnLSimpleHistory( + const response = await getHoldingsProtocolReturnHistory( address, version, fetchType, paginationMode, timeframe, - vault ?? undefined, - vault !== null ? Number(chainIdParam) : undefined + vaultFilters ) - debugLog('route', 'completed holdings simple pnl history request', { + debugLog('route', 'completed holdings protocol return history request', { version, timeframe, - vault: vault?.toLowerCase() ?? null, - chainId: vault !== null ? Number(chainIdParam) : null, + vaults: vaultFilters?.map((vault) => `${vault.chainId}:${vault.vaultAddress.toLowerCase()}`) ?? null, fetchType, paginationMode, totalVaults: response.summary.totalVaults, @@ -962,11 +1020,10 @@ async function handleHoldingsPnLSimpleHistory(req: Request): Promise { }) return response } catch (error) { - debugError('route', 'holdings simple pnl history request failed', error, { + debugError('route', 'holdings protocol return history request failed', error, { version, timeframe, - vault: vault?.toLowerCase() ?? null, - chainId: vault !== null ? Number(chainIdParam) : null, + vaults: vaultFilters?.map((vault) => `${vault.chainId}:${vault.vaultAddress.toLowerCase()}`) ?? null, fetchType, paginationMode }) @@ -976,164 +1033,43 @@ async function handleHoldingsPnLSimpleHistory(req: Request): Promise { ) 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 }) } - return Response.json(history, { - headers: { - 'Cache-Control': 'public, s-maxage=300, stale-while-revalidate=600' - } + await updateHoldingsProgress(activeProgressId, { + status: 'complete', + progress: 100, + message: 'Historical user data ready', + detail: `${history.dataPoints.length} chart points` }) - } catch (error) { - console.error('Error fetching holdings simple PnL 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 simple PnL history', message, stack, status: 502 }, - { status: 502 } - ) - } -} - -async function handleHoldingsPnLDrilldown(req: Request): Promise { - const url = new URL(req.url) - const address = url.searchParams.get('address') - const vault = url.searchParams.get('vault') - const versionParam = url.searchParams.get('version') - 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') ?? vault - const debugTx = url.searchParams.get('debugTx') - const unknownTransferInPnlMode = parseUnknownTransferInPnlMode(url.searchParams.get('unknownMode')) - const fetchType = parseHoldingsEventFetchType(url.searchParams.get('fetchType')) - const paginationMode = parseHoldingsEventPaginationMode(url.searchParams.get('paginationMode')) - 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 (vault !== null && !isValidAddress(vault)) { - return Response.json({ error: 'Invalid vault address', status: 400 }, { status: 400 }) - } - - const version: VaultVersion = versionParam === 'v2' || versionParam === 'v3' ? versionParam : 'all' - - try { - const pnl = await withHoldingsDebugContext( - createHoldingsDebugContext('pnl', address, debugEnabled, { - lotsEnabled: debugLotsEnabled, - vaultFilter: debugVault, - txFilter: debugTx - }), - async () => { - debugLog('route', 'started holdings pnl drilldown request', { - version, - unknownTransferInPnlMode, - fetchType, - paginationMode, - vault: vault?.toLowerCase() ?? null, - debugLotsEnabled, - debugVault: debugVault?.toLowerCase() ?? null, - debugTx: debugTx?.toLowerCase() ?? null - }) - - try { - const response = await getHoldingsPnLDrilldown( - address, - version, - unknownTransferInPnlMode, - fetchType, - paginationMode, - vault - ) - debugLog('route', 'completed holdings pnl drilldown request', { - version, - unknownTransferInPnlMode, - fetchType, - paginationMode, - vault: vault?.toLowerCase() ?? null, - totalVaults: response.summary.totalVaults, - totalCurrentValueUsd: response.summary.totalCurrentValueUsd, - totalPnlUsd: response.summary.totalPnlUsd, - totalEconomicGainUsd: response.summary.totalEconomicGainUsd - }) - return response - } catch (error) { - debugError('route', 'holdings pnl drilldown request failed', error, { - version, - unknownTransferInPnlMode, - fetchType, - paginationMode, - vault: vault?.toLowerCase() ?? null - }) - throw error - } - } - ) - - if (pnl.summary.totalVaults === 0) { - return Response.json( - { - error: vault ? 'No matching holdings found for address and vault' : 'No holdings found for address', - status: 404 - }, - { status: 404 } - ) - } - - return Response.json(pnl, { + return Response.json(history, { headers: { 'Cache-Control': 'public, s-maxage=300, stale-while-revalidate=600' } }) } catch (error) { - console.error('Error fetching holdings PnL drilldown:', 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 PnL drilldown', message, stack, status: 502 }, + { error: 'Failed to fetch holdings protocol return history', message, stack, status: 502 }, { status: 502 } ) } } -async function handleHoldingsChores(req: Request): Promise { - if (req.method !== 'POST') { - return Response.json({ error: 'Method not allowed' }, { status: 405 }) - } - - const authHeader = req.headers.get('authorization') - const cronSecret = process.env.CRON_SECRET - - if (!cronSecret) { - console.error('[Chores] CRON_SECRET not configured') - return Response.json({ error: 'Server misconfigured' }, { status: 500 }) - } - - if (authHeader !== `Bearer ${cronSecret}`) { - return Response.json({ error: 'Unauthorized' }, { status: 401 }) - } - - try { - await initializeSchema() - const deletedCount = await deleteStaleCache() - - return Response.json({ - success: true, - deletedRows: deletedCount, - timestamp: new Date().toISOString() - }) - } catch (error) { - console.error('[Chores] Failed to run cleanup:', error) - return Response.json({ error: 'Cleanup failed' }, { status: 500 }) - } -} - async function handleInvalidateCache(req: Request): Promise { if (req.method !== 'POST') { return Response.json({ error: 'Method not allowed' }, { status: 405 }) @@ -1235,32 +1171,27 @@ async function main() { return withCors(await handleHoldingsHistory(req)) } - if (url.pathname === '/api/holdings/activity') { - return withCors(await handleHoldingsActivity(req)) + if (url.pathname === '/api/holdings/progress') { + return withCors(await handleHoldingsProgress(req)) } - if (url.pathname === '/api/holdings/breakdown') { - return withCors(await handleHoldingsBreakdown(req)) - } - - if (url.pathname === '/api/holdings/pnl/drilldown') { - return withCors(await handleHoldingsPnLDrilldown(req)) - } - - if (url.pathname === '/api/holdings/pnl/simple') { - return withCors(await handleHoldingsPnLSimple(req)) + if (url.pathname === '/api/holdings/activity') { + return withCors(await handleHoldingsActivity(req)) } - if (url.pathname === '/api/holdings/pnl/simple-history') { - return withCors(await handleHoldingsPnLSimpleHistory(req)) + if (url.pathname === '/api/holdings/activity-facets') { + return withCors(await handleHoldingsActivityFacets(req)) } - if (url.pathname === '/api/holdings/pnl') { - return withCors(await handleHoldingsPnL(req)) + if (url.pathname === '/api/holdings/breakdown') { + return withCors(await handleHoldingsBreakdown(req)) } - if (url.pathname === '/api/holdings/chores') { - return withCors(await handleHoldingsChores(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') { diff --git a/bun.lock b/bun.lock index 7a36b84b3..3e86105e4 100644 --- a/bun.lock +++ b/bun.lock @@ -6,8 +6,8 @@ "name": "yearnfi", "dependencies": { "@headlessui/react": "2.2.10", - "@neondatabase/serverless": "^1.0.2", - "@plausible-analytics/tracker": "^0.4.5", + "@neondatabase/serverless": "1.0.2", + "@plausible-analytics/tracker": "0.4.5", "@rainbow-me/rainbowkit": "2.2.11", "@react-hookz/web": "24.0.4", "@tanstack/react-query": "5.90.21", 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/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 index 0984488c8..54c36090f 100644 --- a/src/components/pages/portfolio/components/PortfolioHistoryBreakdownModal.tsx +++ b/src/components/pages/portfolio/components/PortfolioHistoryBreakdownModal.tsx @@ -7,17 +7,16 @@ import { type TKongVaultInput } from '@pages/vaults/domain/kongVaultSelectors' import { getVaultPrimaryLogoSrc } from '@pages/vaults/utils/vaultLogo' -import { isYvUsdAddress, YVUSD_CHAIN_ID, YVUSD_UNLOCKED_ADDRESS } from '@pages/vaults/utils/yvUsd' 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 } from 'react' +import { Fragment, useEffect, useMemo, useState } from 'react' import { Link } from 'react-router' import { usePortfolioBreakdown } from '../hooks/usePortfolioBreakdown' -import type { TPortfolioBreakdownVault } from '../types/api' +import type { TPortfolioBreakdownResponse, TPortfolioBreakdownVault } from '../types/api' type TPortfolioHistoryBreakdownModalProps = { date: string | null @@ -81,64 +80,14 @@ function getChainName(chainId: number): string { return SUPPORTED_NETWORKS.find((network) => network.id === chainId)?.name ?? `Chain ${chainId}` } -function getStatusPriority(status: TPortfolioBreakdownVault['status']): number { - switch (status) { - case 'missing_metadata': - return 3 - case 'missing_pps': - return 2 - case 'missing_price': - return 1 - default: - return 0 - } -} - -function getMergedStatus(statuses: TPortfolioBreakdownVault['status'][]): TPortfolioBreakdownVault['status'] { - return statuses.reduce((selectedStatus, currentStatus) => { - return getStatusPriority(currentStatus) > getStatusPriority(selectedStatus) ? currentStatus : selectedStatus - }, 'ok') -} - -function getBreakdownDisplayKey(vault: TPortfolioBreakdownVault): string { - if (vault.chainId === YVUSD_CHAIN_ID && isYvUsdAddress(vault.vaultAddress)) { - return `${YVUSD_CHAIN_ID}:${YVUSD_UNLOCKED_ADDRESS}` - } - - return `${vault.chainId}:${toAddress(vault.vaultAddress)}` -} - function getBreakdownDisplayVaults(vaults: TPortfolioBreakdownVault[]): TBreakdownDisplayVault[] { - const groupedVaults = new Map() - - vaults.forEach((vault) => { - const displayKey = getBreakdownDisplayKey(vault) - const normalizedVaultAddress = - vault.chainId === YVUSD_CHAIN_ID && isYvUsdAddress(vault.vaultAddress) - ? YVUSD_UNLOCKED_ADDRESS - : toAddress(vault.vaultAddress) - - const existingVault = groupedVaults.get(displayKey) - if (!existingVault) { - groupedVaults.set(displayKey, { - chainId: vault.chainId, - vaultAddress: normalizedVaultAddress, - usdValue: vault.usdValue ?? 0, - status: vault.status, - metadata: vault.metadata - }) - return - } - - groupedVaults.set(displayKey, { - ...existingVault, - usdValue: existingVault.usdValue + (vault.usdValue ?? 0), - status: getMergedStatus([existingVault.status, vault.status]), - metadata: existingVault.metadata ?? vault.metadata - }) - }) - - return [...groupedVaults.values()] + return vaults.map((vault) => ({ + chainId: vault.chainId, + vaultAddress: toAddress(vault.vaultAddress), + usdValue: vault.usdValue ?? 0, + status: vault.status, + metadata: vault.metadata + })) } export function PortfolioHistoryBreakdownModal({ @@ -148,6 +97,20 @@ export function PortfolioHistoryBreakdownModal({ }: 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) { @@ -188,16 +151,12 @@ export function PortfolioHistoryBreakdownModal({ }, [isOpen]) const enrichedVaults = useMemo(() => { - const displayedVaults = getBreakdownDisplayVaults(data?.vaults ?? []) - const yvUsdDisplayVault = allVaults[YVUSD_UNLOCKED_ADDRESS] as TKongVaultInput | undefined + const displayedVaults = getBreakdownDisplayVaults(resolvedData?.vaults ?? []) return displayedVaults .map((vault): TEnrichedBreakdownVault => { const normalizedVaultAddress = toAddress(vault.vaultAddress) - const isMergedYvUsdVault = vault.chainId === YVUSD_CHAIN_ID && normalizedVaultAddress === YVUSD_UNLOCKED_ADDRESS - const currentVault = isMergedYvUsdVault - ? yvUsdDisplayVault - : (allVaults[normalizedVaultAddress] as TKongVaultInput | undefined) + 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` @@ -210,11 +169,7 @@ export function PortfolioHistoryBreakdownModal({ 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) - : isMergedYvUsdVault - ? `${import.meta.env.BASE_URL || '/'}yvUSD-seal.png` - : fallbackLogoSrc, + 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, @@ -223,7 +178,7 @@ export function PortfolioHistoryBreakdownModal({ } }) .sort((leftVault, rightVault) => rightVault.usdValue - leftVault.usdValue) - }, [allVaults, data?.vaults]) + }, [allVaults, resolvedData?.vaults]) const title = `Vault breakdown on ${formatBreakdownDate(date)}` const issueCount = enrichedVaults.filter((vault) => vault.status !== 'ok').length @@ -264,9 +219,9 @@ export function PortfolioHistoryBreakdownModal({ {title} - {data ? ( + {resolvedData ? (

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

) : null}
@@ -292,7 +247,7 @@ export function PortfolioHistoryBreakdownModal({

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

- ) : data && enrichedVaults.length > 0 ? ( + ) : resolvedData && enrichedVaults.length > 0 ? (
{issueCount > 0 ? (

- {data?.message || 'No vault breakdown available for this date.'} + {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 index 7241cff8e..938c37dea 100644 --- a/src/components/pages/portfolio/components/PortfolioHistoryChart.tsx +++ b/src/components/pages/portfolio/components/PortfolioHistoryChart.tsx @@ -15,16 +15,17 @@ import { getChartWeeklyTicks, getTimeframeLimit } from '@pages/vaults/utils/charts' -import { Tooltip } from '@shared/components/Tooltip' +import { YearnLogoSpinner } from '@shared/components/YearnLogoSpinner' import { useWeb3 } from '@shared/contexts/useWeb3' import { useYearn } from '@shared/contexts/useYearn' -import { IconSpinner } from '@shared/icons/IconSpinner' +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, @@ -37,8 +38,9 @@ import type { TPortfolioVaultGrowthChartMode, TPortfolioVaultGrowthChartSeries } import { PortfolioVaultGrowthChart } from './PortfolioVaultGrowthChart' export type TPortfolioHistoryChartTimeframe = '30d' | '90d' | '1y' | 'all' -type TPortfolioHistoryChartTab = 'balance' | 'growth' | 'annualized' | 'index' -type TGrowthDisplayMode = 'index' | 'usd' | 'eth' +export type TPortfolioHistoryChartTab = 'balance' | 'growth' | 'annualized' | 'index' +export type TGrowthDisplayMode = 'index' | 'usd' | 'eth' +type TPortfolioHistoryValueType = TGrowthDisplayMode | TPortfolioVaultGrowthChartMode type TPortfolioHistoryChartProps = { balanceData: TPortfolioHistoryChartData | null @@ -46,9 +48,12 @@ type TPortfolioHistoryChartProps = { protocolReturnSummary: TPortfolioProtocolReturnHistorySummary | null protocolReturnFamilySeries: TPortfolioProtocolReturnHistoryFamilySeries denomination: TPortfolioHistoryDenomination - onDenominationChange: (denomination: TPortfolioHistoryDenomination) => void timeframe: TPortfolioHistoryChartTimeframe - onTimeframeChange: (timeframe: TPortfolioHistoryChartTimeframe) => void + activeTab: TPortfolioHistoryChartTab + growthDisplayModeOverride: TGrowthDisplayMode | null + onGrowthDisplayModeOverrideChange: (mode: TGrowthDisplayMode | null) => void + vaultGrowthMode: TPortfolioVaultGrowthChartMode + onVaultGrowthModeChange: (mode: TPortfolioVaultGrowthChartMode) => void balanceIsLoading: boolean balanceIsEmpty?: boolean balanceError?: Error | null @@ -56,12 +61,19 @@ type TPortfolioHistoryChartProps = { 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 = { @@ -71,6 +83,7 @@ type TPortfolioHistoryTooltipProps = { payload?: { date?: string value?: unknown + isLive?: boolean [key: string]: unknown } }> @@ -80,47 +93,84 @@ 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 GROWTH_DISPLAY_TOOLTIP_COPY: Record = { - index: { - title: 'Index', - body: 'Normalized protocol-return index that starts at 100 for the selected timeframe. Best for mixed-asset wallets.' - }, - usd: { - title: 'USD', - body: 'Protocol growth during the selected timeframe in receipt-time USD equivalent.' - }, - eth: { - title: 'ETH', - body: 'Protocol growth during the selected timeframe in receipt-time ETH equivalent.' - } -} -const VAULT_GROWTH_MODES: Array<{ id: TPortfolioVaultGrowthChartMode; label: string }> = [ +const VAULT_GROWTH_VALUE_TYPES: Array<{ id: TPortfolioVaultGrowthChartMode; label: string }> = [ { id: 'position', label: 'Position' }, { id: 'index', label: 'Index' } ] -const VAULT_GROWTH_MODE_TOOLTIP_COPY: Record = { - position: { - title: 'Position', - body: 'Shows actual protocol gain from your deposited positions during the selected timeframe.' - }, - index: { - title: 'Index', - body: 'Shows vault performance normalized to 100, ignoring position size.' - } -} - 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 }, @@ -226,58 +276,225 @@ function rebaseDeltaPoints(points: TChartPoint[]): TChartPoint[] { })) } -function renderGrowthDisplayTooltip(mode: TGrowthDisplayMode): ReactElement { - const copy = GROWTH_DISPLAY_TOOLTIP_COPY[mode] - +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 ( -
-

{copy.title}

-

{copy.body}

-
+ ) } -function renderVaultGrowthModeTooltip(mode: TPortfolioVaultGrowthChartMode): ReactElement { - const copy = VAULT_GROWTH_MODE_TOOLTIP_COPY[mode] +function resolveGrowthDisplayMode( + selectedMode: TGrowthDisplayMode, + protocolReturnData: TPortfolioProtocolReturnHistoryChartData | null +): TGrowthDisplayMode { + const hasEthSeries = Boolean(protocolReturnData?.some((point) => point.growthWeightEth !== null)) + + return selectedMode === 'eth' && !hasEthSeries ? 'index' : selectedMode +} - return ( -
-

{copy.title}

-

{copy.body}

-
- ) +export function resolvePortfolioGrowthDisplayMode( + selectedMode: TGrowthDisplayMode, + protocolReturnData: TPortfolioProtocolReturnHistoryChartData | null +): TGrowthDisplayMode { + return resolveGrowthDisplayMode(selectedMode, protocolReturnData) } -function renderChartTitleTooltip( - activeTab: TPortfolioHistoryChartTab, - growthDisplayMode: TGrowthDisplayMode -): ReactElement { +function PortfolioHistoryChartLoading({ + serverProgress +}: { + serverProgress?: TPortfolioHistoryChartProps['loadingProgress'] +}): ReactElement { + const displayedMessage = serverProgress?.message ?? 'Building portfolio history' + const detail = serverProgress?.detail + return (
- {getChartDescription(activeTab, growthDisplayMode)} + + {displayedMessage} + {detail ? {detail} : null} + {serverProgress ? ( +
+
+
+ ) : null}
) } -function resolveGrowthDisplayMode( - selectedMode: TGrowthDisplayMode, - protocolReturnData: TPortfolioProtocolReturnHistoryChartData | null -): TGrowthDisplayMode { - const hasEthSeries = Boolean(protocolReturnData?.some((point) => point.growthWeightEth !== 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 + } - return selectedMode === 'eth' && !hasEthSeries ? 'index' : selectedMode + 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( @@ -334,7 +551,7 @@ function PortfolioHistoryTooltip({ return (
@@ -345,49 +562,13 @@ function PortfolioHistoryTooltip({
{activeTab === 'balance' ? ( - {value > 0 ? 'Click to see breakdown' : 'No breakdown available for this point'} + {value > 0 && !point?.isLive ? 'Click to see breakdown' : 'No breakdown available for this point'} ) : null}
) } -function getChartDescription(activeTab: TPortfolioHistoryChartTab, growthDisplayMode: TGrowthDisplayMode): string { - if (activeTab === 'growth') { - return growthDisplayMode === 'index' - ? 'Normalized protocol-return index for the whole wallet. Starts at 100 at the beginning of the selected timeframe.' - : growthDisplayMode === 'eth' - ? 'Protocol growth earned during the selected timeframe, shown in receipt-time ETH equivalent.' - : 'Protocol growth earned during the selected timeframe, shown in receipt-time USD equivalent.' - } - - if (activeTab === 'index') { - return 'Compare actual vault contribution with normalized vault performance.' - } - - if (activeTab === 'annualized') { - return 'Annualized protocol return based on time-weighted baseline exposure up to each point.' - } - - return 'Daily settled portfolio value across your current Yearn activity.' -} - -function getChartTitle(activeTab: TPortfolioHistoryChartTab, growthDisplayMode: TGrowthDisplayMode): string { - if (activeTab === 'growth') { - return growthDisplayMode === 'index' ? 'Growth Index' : 'Protocol Growth' - } - - if (activeTab === 'annualized') { - return 'Annualized Return' - } - - if (activeTab === 'index') { - return 'Growth Index' - } - - return 'Holdings History' -} - function getEmptyMessage(activeTab: TPortfolioHistoryChartTab, growthDisplayMode: TGrowthDisplayMode): string { if (activeTab === 'growth') { return growthDisplayMode === 'index' @@ -414,9 +595,12 @@ export function PortfolioHistoryChart({ protocolReturnSummary, protocolReturnFamilySeries, denomination, - onDenominationChange, timeframe, - onTimeframeChange, + activeTab, + growthDisplayModeOverride, + onGrowthDisplayModeOverrideChange, + vaultGrowthMode, + onVaultGrowthModeChange, balanceIsLoading, balanceIsEmpty = false, balanceError, @@ -424,13 +608,12 @@ export function PortfolioHistoryChart({ protocolReturnIsEmpty = false, protocolReturnError, embedded = false, + reserveControlSpace = true, + loadingProgress, className }: TPortfolioHistoryChartProps): ReactElement { const { address } = useWeb3() const { allVaults } = useYearn() - const [activeTab, setActiveTab] = useState('balance') - const [growthDisplayModeOverride, setGrowthDisplayModeOverride] = useState(null) - const [vaultGrowthMode, setVaultGrowthMode] = useState('position') const [hoveredBreakdownDate, setHoveredBreakdownDate] = useState(null) const [selectedBreakdownDate, setSelectedBreakdownDate] = useState(null) const [isBreakdownModalOpen, setIsBreakdownModalOpen] = useState(false) @@ -445,11 +628,13 @@ export function PortfolioHistoryChart({ ) useEffect(() => { - setGrowthDisplayModeOverride(null) - }, [address]) + onGrowthDisplayModeOverrideChange(null) + }, [address, onGrowthDisplayModeOverrideChange]) const sectionClassName = embedded - ? 'flex h-full flex-col gap-3 bg-surface px-5 py-4 md:px-6 md:pb-0 md:pt-5' + ? 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(() => { @@ -459,7 +644,7 @@ export function PortfolioHistoryChart({ 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 })) + return points.map((point) => ({ date: point.date, value: point.value, isLive: point.isLive })) }, [balanceData, timeframe]) const filteredGrowthUsdData = useMemo(() => { @@ -559,6 +744,19 @@ export function PortfolioHistoryChart({ 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' @@ -568,7 +766,11 @@ export function PortfolioHistoryChart({ ) const tickFormatter = isShortTimeframe ? formatChartWeekLabel : formatChartMonthYearLabel - const formatValueTick = (value: number | string) => { + const formatValueTick = (value: number | string, index?: number) => { + if (index === 0) { + return '' + } + const numericValue = Number(value) const absoluteValue = Math.abs(numericValue) if (numericValue === 0) { @@ -657,7 +859,7 @@ export function PortfolioHistoryChart({ return { value: { label: denomination === 'eth' ? 'Example Value (ETH)' : 'Example Value (USD)', - color: 'var(--color-neutral-400)' + color: 'var(--chart-1)' } } }, [denomination]) @@ -675,7 +877,8 @@ export function PortfolioHistoryChart({ return null } - return filteredBalanceData.find((point) => point.date === date) ?? null + const point = filteredBalanceData.find((balancePoint) => balancePoint.date === date) ?? null + return point?.isLive ? null : point } const handleChartMouseMove = (state: TActiveChartState | undefined): void => { @@ -710,145 +913,10 @@ export function PortfolioHistoryChart({ setIsBreakdownModalOpen(true) } - const renderHeader = (showControls = false): ReactElement => ( -
-
-
- -

- {getChartTitle(activeTab, resolvedGrowthDisplayMode)} -

-
- {activeTab === 'balance' ? ( -
- {(['usd', 'eth'] as const).map((nextDenomination) => ( - - ))} -
- ) : activeTab === 'growth' ? ( -
- {GROWTH_DISPLAY_MODES.map((mode) => ( - - - - ))} -
- ) : activeTab === 'index' ? ( -
- {VAULT_GROWTH_MODES.map((mode) => ( - - - - ))} -
- ) : null} -
-
- {showControls ? ( -
-
- {CHART_TABS.map((tab) => ( - - ))} -
-
- {(['30d', '90d', '1y', 'all'] as const).map((tf) => ( - - ))} -
-
- ) : null} -
- ) - if (activeIsLoading) { return (
- {renderHeader(true)} -
- -
+
) } @@ -856,7 +924,6 @@ export function PortfolioHistoryChart({ if (activeError) { return (
- {renderHeader(true)}

{activeTab === 'balance' @@ -871,7 +938,6 @@ export function PortfolioHistoryChart({ if (activeIsEmpty && activeTab === 'balance') { return (

- {renderHeader(true)}
- - + + @@ -916,7 +982,7 @@ export function PortfolioHistoryChart({
-

- {'This is what your portfolio history can look like.'} -

-

- {'Your Yearn deposits, yield, and vault rotations will start drawing a story here.'} -

-

+

{ - 'Once you hold a Yearn vault, this chart will turn into a live timeline of your daily portfolio value.' + 'Once you have a deposit in a Yearn Vault, you’ll see your portfolio balances and growth over time here.' } -

+

@@ -959,7 +1019,6 @@ export function PortfolioHistoryChart({ if ((activeIsEmpty || activeData.length === 0 || !activeHasRenderableValue) && activeTab !== 'index') { return (
- {renderHeader(true)}

{getEmptyMessage(activeTab, resolvedGrowthDisplayMode)}

@@ -972,18 +1031,17 @@ export function PortfolioHistoryChart({ return (
- {renderHeader(true)} - {renderHeader(true)} -
+
(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 @@ -58,7 +95,7 @@ export type TPortfolioVaultGrowthChartProps = { indexBase?: number colors?: string[] title?: string - height?: number + height?: number | string showModeToggle?: boolean className?: string emptyMessage?: string @@ -99,6 +136,10 @@ type TTooltipProps = { } 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 = { @@ -467,7 +508,11 @@ function formatIndexValue(value: number): string { return value >= 1000 ? value.toFixed(0) : value >= 100 ? value.toFixed(1) : value.toFixed(2) } -function formatPositionTick(value: number | string): string { +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) { @@ -479,7 +524,11 @@ function formatPositionTick(value: number | string): string { return `${numericValue < 0 ? '-' : ''}$${absoluteValue.toFixed(0)}` } -function formatIndexTick(value: number | string): string { +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) } @@ -582,6 +631,26 @@ export function PortfolioVaultGrowthChart({ 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 }]) @@ -643,9 +712,9 @@ export function PortfolioVaultGrowthChart({

{emptyMessage}

) : ( -
+
- + { const vaultKey = getVaultKey(suggestion.vault) diff --git a/src/components/pages/portfolio/hooks/usePortfolioActivity.ts b/src/components/pages/portfolio/hooks/usePortfolioActivity.ts index d1cc6b72d..e51dafda1 100644 --- a/src/components/pages/portfolio/hooks/usePortfolioActivity.ts +++ b/src/components/pages/portfolio/hooks/usePortfolioActivity.ts @@ -1,40 +1,164 @@ import { useWeb3 } from '@shared/contexts/useWeb3' import { fetchWithSchema } from '@shared/hooks/useFetch' -import { useInfiniteQuery } from '@tanstack/react-query' +import { useInfiniteQuery, useQuery } from '@tanstack/react-query' +import { useEffect, useState } from 'react' import { + portfolioActivityFacetsResponseSchema, portfolioActivityResponseSchema, type TPortfolioActivityEntry, - type TPortfolioActivityResponse + type TPortfolioActivityTypeFilter } from '../types/api' -export function usePortfolioActivity(limit = 10, enabled = true) { +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], + const query = useInfiniteQuery({ + queryKey: ['portfolio-activity', address, limit, type, chainId, startTimestamp, endTimestamp], enabled: isEnabled, initialPageParam: 0, - queryFn: ({ pageParam }) => - fetchWithSchema( - `/api/holdings/activity?address=${address}&limit=${limit}&offset=${Number(pageParam) || 0}`, - portfolioActivityResponseSchema, - { timeout: 30 * 1000 } - ), + 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 + 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: query.error, + error, isEmpty, hasMore: Boolean(query.hasNextPage), loadMore: () => query.fetchNextPage() 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 index 90fde4b4e..a163d3001 100644 --- a/src/components/pages/portfolio/hooks/usePortfolioHistory.ts +++ b/src/components/pages/portfolio/hooks/usePortfolioHistory.ts @@ -5,23 +5,39 @@ import type { TPortfolioHistoryChartData, TPortfolioHistoryDenomination, TPortfolioHistorySimpleResponse, - TPortfolioHistoryTimeframe + 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 + 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) { + if (!address || !enabled || !progressId) { return null } - return `/api/holdings/history?address=${address}&denomination=${denomination}&timeframe=${timeframe}&debug=1&fetchType=parallel` - }, [address, denomination, enabled, timeframe]) + 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, @@ -32,7 +48,9 @@ export function usePortfolioHistory( endpoint, schema: portfolioHistorySimpleResponseSchema, config: { - cacheDuration: 5 * 60 * 1000, + cacheKey, + cacheDuration: PORTFOLIO_HISTORY_CACHE_DURATION, + gcTime: PORTFOLIO_HISTORY_CACHE_DURATION, keepPreviousData: false, timeout: 2 * 60 * 1000 // 2 minutes for large holdings requests } @@ -42,24 +60,29 @@ export function usePortfolioHistory( if (!rawData?.dataPoints) { return null } - return rawData.dataPoints.map((point) => ({ + + const historicalData = rawData.dataPoints.map((point) => ({ date: point.date, value: point.value })) - }, [rawData]) + return upsertLivePortfolioBalancePoint({ data: historicalData, denomination, liveSnapshot }) + }, [denomination, liveSnapshot, rawData]) - const isLoadingState = isLoading || isFetching + 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.ts b/src/components/pages/portfolio/hooks/usePortfolioModel.ts index 31bb19e43..bea71202c 100644 --- a/src/components/pages/portfolio/hooks/usePortfolioModel.ts +++ b/src/components/pages/portfolio/hooks/usePortfolioModel.ts @@ -1,3 +1,4 @@ +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' @@ -33,8 +34,10 @@ 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' type THoldingsRow = { @@ -44,8 +47,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 = { @@ -66,6 +76,7 @@ export type TPortfolioModel = { sortBy: TPossibleSortBy sortDirection: TSortDirection suggestedRows: TSuggestedItem[] + liveBalanceSnapshot: TPortfolioLiveBalanceSnapshot | null totalPortfolioValue: number vaultFlags: Record setSortBy: TSortStateSetter @@ -79,6 +90,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'], @@ -111,17 +146,40 @@ 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() @@ -287,7 +345,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( () => @@ -315,6 +373,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) @@ -330,14 +390,37 @@ export function usePortfolioModel(): TPortfolioModel { ) const suggestedRows = useMemo((): TSuggestedItem[] => { + const yvUsdSuggestedVaultKey = yvUsdSuggestedVault ? getVaultKey(yvUsdSuggestedVault) : null + const yvUsdSuggestion = + yvUsdSuggestedVault && yvUsdSuggestedVaultKey && !holdingsKeySet.has(yvUsdSuggestedVaultKey) + ? { + 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) })), @@ -346,7 +429,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) })), @@ -365,7 +449,7 @@ 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( @@ -373,6 +457,31 @@ export function usePortfolioModel(): TPortfolioModel { [sortedHoldings] ) const totalPortfolioValue = (cumulatedValueInV2Vaults || 0) + (cumulatedValueInV3Vaults || 0) + const ethPrice = getPrice({ address: ETH_TOKEN_ADDRESS, chainID: 1 }).normalized + + const liveBalanceSnapshot = useMemo(() => { + if (isHoldingsLoading || !Number.isFinite(totalPortfolioValue)) { + return null + } + + 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 { + 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) @@ -416,6 +525,7 @@ export function usePortfolioModel(): TPortfolioModel { sortBy, sortDirection, suggestedRows, + liveBalanceSnapshot, totalPortfolioValue, vaultFlags, setSortBy, diff --git a/src/components/pages/portfolio/hooks/usePortfolioPnL.ts b/src/components/pages/portfolio/hooks/usePortfolioPnL.ts deleted file mode 100644 index c03b4421e..000000000 --- a/src/components/pages/portfolio/hooks/usePortfolioPnL.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { useWeb3 } from '@shared/contexts/useWeb3' -import { useFetch } from '@shared/hooks/useFetch' -import { useMemo } from 'react' -import { portfolioPnlResponseSchema, type TPortfolioPnlSummary } from '../types/api' - -export function usePortfolioPnL() { - const { address } = useWeb3() - - const endpoint = useMemo(() => { - if (!address) { - return null - } - - return `/api/holdings/pnl?address=${address}&debug=1&fetchType=parallel` - }, [address]) - - const { data, isLoading, isFetching, error } = useFetch({ - endpoint, - schema: portfolioPnlResponseSchema, - config: { - cacheDuration: 5 * 60 * 1000, - keepPreviousData: false, - timeout: 2 * 60 * 1000 // 2 minutes for large holdings requests - } - }) - - return { - data: (data?.summary ?? null) as TPortfolioPnlSummary | null, - isLoading: isLoading || isFetching, - error - } -} diff --git a/src/components/pages/portfolio/hooks/usePortfolioProtocolReturn.ts b/src/components/pages/portfolio/hooks/usePortfolioProtocolReturn.ts deleted file mode 100644 index 1a0b69050..000000000 --- a/src/components/pages/portfolio/hooks/usePortfolioProtocolReturn.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { useWeb3 } from '@shared/contexts/useWeb3' -import { useFetch } from '@shared/hooks/useFetch' -import { useEffect, useMemo } from 'react' -import { - portfolioProtocolReturnResponseSchema, - type TPortfolioProtocolReturnResponse, - type TPortfolioProtocolReturnSummary, - type TPortfolioProtocolReturnVault -} from '../types/api' - -export function usePortfolioProtocolReturn(enabled = true) { - const { address } = useWeb3() - - const endpoint = useMemo(() => { - if (!address || !enabled) { - return null - } - - return `/api/holdings/pnl/simple?address=${address}&debug=1&fetchType=parallel` - }, [address, enabled]) - - const { data, isLoading, isFetching, error } = useFetch({ - endpoint, - schema: portfolioProtocolReturnResponseSchema, - config: { - cacheDuration: 5 * 60 * 1000, - keepPreviousData: false, - timeout: 2 * 60 * 1000 - } - }) - - useEffect(() => { - if (!data || data.summary.isComplete) { - return - } - - const partialVaults = data.vaults - .filter((vault) => vault.status !== 'ok') - .map((vault: TPortfolioProtocolReturnVault) => ({ - chainId: vault.chainId, - vaultAddress: vault.vaultAddress, - symbol: vault.metadata.symbol, - tokenAddress: vault.metadata.tokenAddress, - status: vault.status, - issues: vault.issues - })) - - if (partialVaults.length === 0) { - return - } - - console.groupCollapsed( - `[Portfolio] Protocol return incomplete for ${partialVaults.length} vault${partialVaults.length === 1 ? '' : 's'}` - ) - console.table(partialVaults) - console.groupEnd() - }, [data]) - - return { - data: (data?.summary ?? null) as TPortfolioProtocolReturnSummary | null, - vaults: data?.vaults ?? [], - isLoading: isLoading || isFetching, - error - } -} diff --git a/src/components/pages/portfolio/hooks/usePortfolioProtocolReturnHistory.ts b/src/components/pages/portfolio/hooks/usePortfolioProtocolReturnHistory.ts index 8f8735a55..2d80a740d 100644 --- a/src/components/pages/portfolio/hooks/usePortfolioProtocolReturnHistory.ts +++ b/src/components/pages/portfolio/hooks/usePortfolioProtocolReturnHistory.ts @@ -9,23 +9,36 @@ import { 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) { + if (!address || !enabled || !progressId) { return null } - return `/api/holdings/pnl/simple-history?address=${address}&timeframe=${timeframe}&debug=1&fetchType=parallel` - }, [address, enabled, timeframe]) + return `/api/holdings/protocol-return/history?address=${address}&timeframe=${timeframe}&debug=1&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: { - cacheDuration: 5 * 60 * 1000, + cacheKey, + cacheDuration: PORTFOLIO_HISTORY_CACHE_DURATION, + gcTime: PORTFOLIO_HISTORY_CACHE_DURATION, keepPreviousData: false, timeout: 2 * 60 * 1000 } @@ -49,13 +62,15 @@ export function usePortfolioProtocolReturnHistory(timeframe: TPortfolioHistoryTi const summary = useMemo(() => data?.summary ?? null, [data]) const familySeries = useMemo(() => data?.familySeries ?? [], [data]) - const isLoadingState = isLoading || isFetching + 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, @@ -63,6 +78,7 @@ export function usePortfolioProtocolReturnHistory(timeframe: TPortfolioHistoryTi 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 810945270..ef72f5ed4 100644 --- a/src/components/pages/portfolio/index.tsx +++ b/src/components/pages/portfolio/index.tsx @@ -1,11 +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' @@ -18,42 +30,72 @@ import { 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, 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, 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 { TPortfolioHistoryChartTimeframe } from './components/PortfolioHistoryChart' -import { PortfolioHistoryChart } from './components/PortfolioHistoryChart' +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 { usePortfolioProtocolReturn } from './hooks/usePortfolioProtocolReturn' import { usePortfolioProtocolReturnHistory } from './hooks/usePortfolioProtocolReturnHistory' import type { TPortfolioActivityEntry, + TPortfolioActivityTypeFilter, TPortfolioHistoryDenomination, - TPortfolioHistoryTimeframe, - TPortfolioProtocolReturnSummary + TPortfolioHistoryTimeframe } from './types/api' const currencyFormatter = new Intl.NumberFormat('en-US', { @@ -68,7 +110,7 @@ const headingTooltipClassName = 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 @@ -80,7 +122,7 @@ type TPortfolioHeaderProps = Pick< 'blendedMetrics' | 'hasKatanaHoldings' | 'isHoldingsLoading' | 'isSearchingBalances' | 'totalPortfolioValue' > & { isProtocolReturnLoading: boolean - protocolReturnSummary: TPortfolioProtocolReturnSummary | null + annualizedProtocolReturnPct: number | null | undefined } type TPortfolioHoldingsProps = Pick< @@ -97,7 +139,7 @@ type TPortfolioHoldingsProps = Pick< | 'vaultFlags' > -type TPortfolioSuggestedProps = Pick +type TPortfolioSuggestedProps = Pick type TPortfolioActivityProps = Pick @@ -107,7 +149,49 @@ const ACTIVITY_ACTION_LABELS: Record deposit: 'Deposit', withdraw: 'Withdraw', stake: 'Stake', - unstake: 'Unstake' + 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 { @@ -118,25 +202,82 @@ function formatActivityDisplayAmount(amountFormatted: number | null, symbol: str return `${amountFormatted.toLocaleString(undefined, { maximumFractionDigits: 6 })} ${symbol ?? ''}`.trim() } -function getActivityExplorerUrl(chainId: number, txHash: string): string | null { - const network = SUPPORTED_NETWORKS.find((item) => item.id === chainId) - const explorerBaseUrl = network?.blockExplorers?.default?.url +function formatActivityFixedValue(amountFormatted: number | null): string { + if (amountFormatted === null) { + return 'Unknown' + } - return explorerBaseUrl ? `${explorerBaseUrl}/tx/${txHash}` : null + 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 getActivityAddressExplorerUrl(chainId: number, address: string): string | null { +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}/address/${address}` : null + 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', @@ -146,110 +287,1205 @@ function formatIndexedActivityDate(timestamp: number): string { }) } +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