diff --git a/backend/package-lock.json b/backend/package-lock.json index 318592fd..775825bc 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -21,6 +21,8 @@ "express-rate-limit": "^8.5.2", "express-validator": "^7.0.1", "graphql": "^16.14.2", + "graphql-depth-limit": "^1.1.0", + "graphql-query-complexity": "^2.0.0", "helmet": "8.0.0", "ioredis": "^5.4.2", "node-cron": "^3.0.3", @@ -4310,6 +4312,15 @@ "version": "1.1.1", "license": "MIT" }, + "node_modules/arrify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/arrify/-/arrify-1.0.1.tgz", + "integrity": "sha512-3CYzex9M9FGQjCGMGyi6/31c8GJbgb0qGyrx5HWxPd0aCwh4cB2YjMb2Xf9UuoogrMrlO9cTqnB5rI5GHZTcUA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/asap": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", @@ -5600,6 +5611,33 @@ "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" } }, + "node_modules/graphql-depth-limit": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/graphql-depth-limit/-/graphql-depth-limit-1.1.0.tgz", + "integrity": "sha512-+3B2BaG8qQ8E18kzk9yiSdAa75i/hnnOwgSeAxVJctGQPvmeiLtqKOYF6HETCyRjiF7Xfsyal0HbLlxCQkgkrw==", + "license": "MIT", + "dependencies": { + "arrify": "^1.0.1" + }, + "engines": { + "node": ">=6.0.0" + }, + "peerDependencies": { + "graphql": "*" + } + }, + "node_modules/graphql-query-complexity": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/graphql-query-complexity/-/graphql-query-complexity-2.0.0.tgz", + "integrity": "sha512-gmdNp8Lq3KbJcWeX9mpIqpIxD2oDEVO5QgZtw7eiDB7Q1RYMlqDFQnGq6ZcaDclBNcwTnyOCHUtEr3qAjnUDVg==", + "license": "MIT", + "dependencies": { + "lodash.get": "^4.4.2" + }, + "peerDependencies": { + "graphql": "^16.6.0 || ^17.0.0" + } + }, "node_modules/has-flag": { "version": "4.0.0", "dev": true, @@ -5952,6 +5990,13 @@ "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "license": "MIT" }, + "node_modules/lodash.get": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", + "integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==", + "deprecated": "This package is deprecated. Use the optional chaining (?.) operator instead.", + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "dev": true, diff --git a/backend/package.json b/backend/package.json index c2c0a425..018d7577 100644 --- a/backend/package.json +++ b/backend/package.json @@ -40,6 +40,8 @@ "express-rate-limit": "^8.5.2", "express-validator": "^7.0.1", "graphql": "^16.14.2", + "graphql-depth-limit": "^1.1.0", + "graphql-query-complexity": "^2.0.0", "helmet": "8.0.0", "ioredis": "^5.4.2", "node-cron": "^3.0.3", diff --git a/backend/src/api/controllers/analytics.test.ts b/backend/src/api/controllers/analytics.test.ts new file mode 100644 index 00000000..b2bdce31 --- /dev/null +++ b/backend/src/api/controllers/analytics.test.ts @@ -0,0 +1,81 @@ +import { vi, describe, it, expect, beforeEach } from "vitest"; + +const mocks = vi.hoisted(() => ({ query: vi.fn() })); + +vi.mock("../../db/index.js", () => ({ query: mocks.query })); +vi.mock("../../cache/redis.js", () => ({ cacheGet: vi.fn(), cacheSet: vi.fn() })); + +import { getTvlAggregate } from "./analytics.js"; + +describe("GET /api/v1/analytics/tvl (#775)", () => { + const mockNext = vi.fn(); + + const buildRes = () => { + const res: any = {}; + res.set = vi.fn().mockReturnThis(); + res.json = vi.fn().mockReturnThis(); + return res; + }; + + beforeEach(() => vi.clearAllMocks()); + + it("returns the aggregate TVL, active vault count, and funding vault count", async () => { + mocks.query.mockResolvedValue([ + { total_value_locked: "12345", active_vault_count: "3", funding_vault_count: "2" }, + ]); + const res = buildRes(); + + await getTvlAggregate({} as any, res, mockNext); + + expect(res.json).toHaveBeenCalledWith({ + totalValueLocked: "12345", + activeVaultCount: 3, + fundingVaultCount: 2, + }); + }); + + it("scopes the query to non-archived vaults", async () => { + mocks.query.mockResolvedValue([ + { total_value_locked: "0", active_vault_count: "0", funding_vault_count: "0" }, + ]); + const res = buildRes(); + + await getTvlAggregate({} as any, res, mockNext); + + expect(mocks.query).toHaveBeenCalledWith(expect.stringContaining("WHERE archived = FALSE")); + }); + + it("sets a 30 second Cache-Control header", async () => { + mocks.query.mockResolvedValue([ + { total_value_locked: "0", active_vault_count: "0", funding_vault_count: "0" }, + ]); + const res = buildRes(); + + await getTvlAggregate({} as any, res, mockNext); + + expect(res.set).toHaveBeenCalledWith("Cache-Control", "max-age=30"); + }); + + it("defaults to zeros when there are no vaults", async () => { + mocks.query.mockResolvedValue([]); + const res = buildRes(); + + await getTvlAggregate({} as any, res, mockNext); + + expect(res.json).toHaveBeenCalledWith({ + totalValueLocked: "0", + activeVaultCount: 0, + fundingVaultCount: 0, + }); + }); + + it("forwards errors to next", async () => { + const err = new Error("db down"); + mocks.query.mockRejectedValue(err); + const res = buildRes(); + + await getTvlAggregate({} as any, res, mockNext); + + expect(mockNext).toHaveBeenCalledWith(err); + }); +}); diff --git a/backend/src/api/controllers/analytics.ts b/backend/src/api/controllers/analytics.ts index f0dec659..2136bc9b 100644 --- a/backend/src/api/controllers/analytics.ts +++ b/backend/src/api/controllers/analytics.ts @@ -1,9 +1,10 @@ import type { Request, Response, NextFunction } from "express"; import { query } from "../../db/index.js"; import { cacheGet, cacheSet } from "../../cache/redis.js"; -import type { AnalyticsSummary } from "../../types/index.js"; +import type { AnalyticsSummary, TvlAggregate } from "../../types/index.js"; const ANALYTICS_CACHE_TTL = 60; +const TVL_CACHE_CONTROL = "max-age=30"; export async function getAnalyticsSummary(_req: Request, res: Response, next: NextFunction) { try { @@ -44,3 +45,37 @@ export async function getAnalyticsSummary(_req: Request, res: Response, next: Ne next(err); } } + +/** + * Public cross-vault TVL aggregate (#775). Unlike /api/v1/admin/stats this + * requires no authentication, so platform dashboards can render total TVL + * without an API key. + */ +export async function getTvlAggregate(_req: Request, res: Response, next: NextFunction) { + try { + const rows = await query<{ + total_value_locked: string; + active_vault_count: string; + funding_vault_count: string; + }>( + `SELECT + COALESCE(SUM(total_assets::numeric), 0)::text AS total_value_locked, + COUNT(*) FILTER (WHERE state = 'Active')::text AS active_vault_count, + COUNT(*) FILTER (WHERE state = 'Funding')::text AS funding_vault_count + FROM vaults + WHERE archived = FALSE`, + ); + + const row = rows[0]; + const tvl: TvlAggregate = { + totalValueLocked: row?.total_value_locked ?? "0", + activeVaultCount: parseInt(row?.active_vault_count ?? "0", 10), + fundingVaultCount: parseInt(row?.funding_vault_count ?? "0", 10), + }; + + res.set("Cache-Control", TVL_CACHE_CONTROL); + res.json(tvl); + } catch (err) { + next(err); + } +} diff --git a/backend/src/api/controllers/users.ts b/backend/src/api/controllers/users.ts index 1aceeb3a..c0dfb233 100644 --- a/backend/src/api/controllers/users.ts +++ b/backend/src/api/controllers/users.ts @@ -213,6 +213,36 @@ export async function getUserPortfolioPnl( } } +export async function getUserPortfolioAllocation( + req: Request, + res: Response, + next: NextFunction, +) { + try { + const allocation = await userService.getUserPortfolioAllocation( + String(req.params["address"]), + ); + res.json(allocation); + } catch (err) { + next(err); + } +} + +export async function getUserPortfolioDiversification( + req: Request, + res: Response, + next: NextFunction, +) { + try { + const diversification = await userService.getUserPortfolioDiversification( + String(req.params["address"]), + ); + res.json(diversification); + } catch (err) { + next(err); + } +} + export async function getUserIncomeForecast( req: Request, res: Response, diff --git a/backend/src/api/routes/analytics.ts b/backend/src/api/routes/analytics.ts index 134d266e..951ff6b3 100644 --- a/backend/src/api/routes/analytics.ts +++ b/backend/src/api/routes/analytics.ts @@ -1,6 +1,7 @@ import { Router } from "express"; -import { getAnalyticsSummary } from "../controllers/analytics.js"; +import { getAnalyticsSummary, getTvlAggregate } from "../controllers/analytics.js"; export const analyticsRouter = Router(); analyticsRouter.get("/summary", getAnalyticsSummary); +analyticsRouter.get("/tvl", getTvlAggregate); diff --git a/backend/src/api/routes/users.ts b/backend/src/api/routes/users.ts index 9eb3d6e6..9a9e33c9 100644 --- a/backend/src/api/routes/users.ts +++ b/backend/src/api/routes/users.ts @@ -9,6 +9,8 @@ import { getUserKyc, getUserKycHistory, getUserPortfolio, + getUserPortfolioAllocation, + getUserPortfolioDiversification, getUserPortfolioPnl, getUserShareHistory, getUserYieldHistory, @@ -124,6 +126,16 @@ usersRouter.get( validateParams(addressParamSchema), getUserPortfolioPnl, ); +usersRouter.get( + "/:address/portfolio/allocation", + validateParams(addressParamSchema), + getUserPortfolioAllocation, +); +usersRouter.get( + "/:address/portfolio/diversification", + validateParams(addressParamSchema), + getUserPortfolioDiversification, +); usersRouter.get( "/:address/portfolio/income-forecast", validateParams(addressParamSchema), diff --git a/backend/src/graphql/apolloServer.ts b/backend/src/graphql/apolloServer.ts index aa06e968..23ab9b3e 100644 --- a/backend/src/graphql/apolloServer.ts +++ b/backend/src/graphql/apolloServer.ts @@ -7,6 +7,7 @@ import { config } from "../config.js"; import { schema } from "./schema.js"; import { root } from "./resolvers.js"; import { createGraphQLContext, type GraphQLContext } from "./context.js"; +import { depthLimitRule, complexityLimitRule } from "./queryLimits.js"; /** * Apollo Server instance backed by the existing graphql-js schema/rootValue @@ -17,6 +18,9 @@ export const apolloServer = new ApolloServer({ schema, rootValue: root, introspection: config.nodeEnv !== "production", + // Depth/complexity limits guard against deeply nested or overly broad + // queries driving excessive DB load (#774). + validationRules: [depthLimitRule, complexityLimitRule], plugins: [ config.nodeEnv === "development" ? ApolloServerPluginLandingPageLocalDefault() diff --git a/backend/src/graphql/queryLimits.test.ts b/backend/src/graphql/queryLimits.test.ts new file mode 100644 index 00000000..b9ed11ed --- /dev/null +++ b/backend/src/graphql/queryLimits.test.ts @@ -0,0 +1,92 @@ +import { describe, it, expect } from "vitest"; +import { buildSchema, parse, validate } from "graphql"; +import { depthLimitRule, complexityLimitRule, MAX_QUERY_DEPTH, MAX_QUERY_COMPLEXITY } from "./queryLimits.js"; + +// A schema shaped like the acceptance criteria in #774: an 8-level-deep chain +// of object fields, and a vaults -> positions -> vault cycle to exercise the +// complexity estimator's list handling. +const testSchema = buildSchema(` + type Position { + id: ID! + vaultId: ID! + vault: Vault! + } + + type Vault { + id: ID! + positions: [Position!]! + } + + type L8 { value: String } + type L7 { l8: L8 } + type L6 { l7: L7 } + type L5 { l6: L6 } + type L4 { l5: L5 } + type L3 { l4: L4 } + type L2 { l3: L3 } + type L1 { l2: L2 } + + type Query { + vaults: [Vault!]! + deep: L1 + } +`); + +describe("depthLimitRule (#774)", () => { + it("allows a query at or below the max depth", () => { + const document = parse(`{ vaults { id positions { id vaultId } } }`); + const errors = validate(testSchema, document, [depthLimitRule]); + expect(errors).toHaveLength(0); + }); + + it("rejects a query nested 8 levels deep with a descriptive error", () => { + // 9 nested field selections -> depth 8 (leaf fields contribute 0). + const document = parse(` + query Deep { + deep { l2 { l3 { l4 { l5 { l6 { l7 { l8 { value } } } } } } } } + } + `); + const errors = validate(testSchema, document, [depthLimitRule]); + expect(errors.map((e) => e.message)).toContain( + `Query depth 8 exceeds maximum of ${MAX_QUERY_DEPTH}`, + ); + }); +}); + +describe("complexityLimitRule (#774)", () => { + const vaultSelection = ` + id + positions { + id + vaultId + vault { + id + positions { id vaultId } + } + } + `; + + it("allows a single vaults-with-positions selection under the max complexity", () => { + // vaults(10) + [id(1) + positions(10 + [id(1)+vaultId(1)+vault(1 + [id(1)+positions(10+2)])])] = 37 + const document = parse(`{ vaults { ${vaultSelection} } }`); + const errors = validate(testSchema, document, [complexityLimitRule]); + expect(errors).toHaveLength(0); + }); + + it("rejects a query requesting all vaults with all nested positions as too complex", () => { + // Repeating the same vaults->positions->vault->positions shape via + // aliases mimics a client fanning out the same nested-list fetch many + // times over, each one genuinely costing the DB the same amount. + const aliasedSelections = Array.from( + { length: 7 }, + (_, i) => `v${i}: vaults { ${vaultSelection} }`, + ).join("\n"); + const document = parse(`{ ${aliasedSelections} }`); + + const errors = validate(testSchema, document, [complexityLimitRule]); + expect(errors.length).toBeGreaterThan(0); + expect(errors[0]?.message).toBe( + `Query complexity 259 exceeds maximum of ${MAX_QUERY_COMPLEXITY}`, + ); + }); +}); diff --git a/backend/src/graphql/queryLimits.ts b/backend/src/graphql/queryLimits.ts new file mode 100644 index 00000000..e890d8fa --- /dev/null +++ b/backend/src/graphql/queryLimits.ts @@ -0,0 +1,49 @@ +import { GraphQLError, GraphQLNonNull, isListType } from "graphql"; +import type { ValidationRule } from "graphql"; +// @ts-expect-error graphql-depth-limit ships no type declarations. +import depthLimit from "graphql-depth-limit"; +import { createComplexityRule, type ComplexityEstimator } from "graphql-query-complexity"; + +/** Maximum allowed selection-set nesting for any GraphQL operation (#774). */ +export const MAX_QUERY_DEPTH = 7; + +/** Maximum allowed total query complexity score for any GraphQL operation (#774). */ +export const MAX_QUERY_COMPLEXITY = 200; + +/** + * graphql-depth-limit reports its own ("'opName' exceeds maximum operation + * depth of N") message the instant a selection crosses maxDepth, which + * doesn't match the descriptive message required by #774. So it's given a + * high safety ceiling here — purely to bound pathological recursion — and + * the real enforcement happens in the callback, where we know the actual + * computed depth and can report it. + */ +const DEPTH_SAFETY_CEILING = 50; + +export const depthLimitRule: ValidationRule = (validationContext) => + depthLimit(DEPTH_SAFETY_CEILING, {}, (depths: Record) => { + for (const depth of Object.values(depths)) { + if (depth > MAX_QUERY_DEPTH) { + validationContext.reportError( + new GraphQLError(`Query depth ${depth} exceeds maximum of ${MAX_QUERY_DEPTH}`), + ); + } + } + })(validationContext); + +/** Every field costs 1; a field whose type resolves to a list costs 10 (#774). */ +const listAwareEstimator: ComplexityEstimator = ({ field, childComplexity }) => { + let type = field.type; + while (type instanceof GraphQLNonNull) { + type = type.ofType; + } + const fieldCost = isListType(type) ? 10 : 1; + return fieldCost + childComplexity; +}; + +export const complexityLimitRule: ValidationRule = createComplexityRule({ + maximumComplexity: MAX_QUERY_COMPLEXITY, + estimators: [listAwareEstimator], + createError: (max, actual) => + new GraphQLError(`Query complexity ${actual} exceeds maximum of ${max}`), +}); diff --git a/backend/src/services/user.portfolio-analytics.test.ts b/backend/src/services/user.portfolio-analytics.test.ts new file mode 100644 index 00000000..8ad64b23 --- /dev/null +++ b/backend/src/services/user.portfolio-analytics.test.ts @@ -0,0 +1,105 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { UserService } from "./user.js"; +import * as db from "../db/index.js"; + +vi.mock("../db/index.js"); +vi.mock("../logger.js", () => ({ + logger: { info: vi.fn(), error: vi.fn(), warn: vi.fn(), debug: vi.fn() }, +})); +vi.mock("./yield.js", () => ({ + YieldService: vi.fn(() => ({ + getUserPendingYield: vi.fn().mockResolvedValue({ pendingYield: "0", epochs: [] }), + })), +})); + +const TEST_ADDRESS = "GBRPYHIL2CI3WHZDTOOQFC6EB4KJJGUJJBBX7UYXVXPXD5XNMJXVXV"; + +describe("UserService.getUserPortfolioAllocation (#776)", () => { + let userService: UserService; + + beforeEach(() => { + userService = new UserService(); + vi.clearAllMocks(); + }); + + it("returns an empty allocations array for a user with no positions", async () => { + vi.mocked(db.query).mockResolvedValueOnce([]); + const result = await userService.getUserPortfolioAllocation(TEST_ADDRESS); + expect(result).toEqual({ allocations: [] }); + }); + + it("groups deposited amounts by rwa_category with percentages summing to 100", async () => { + vi.mocked(db.query).mockResolvedValueOnce([ + { category: "Real Estate", deposited: "600" }, + { category: "Treasury", deposited: "300" }, + { category: "Uncategorized", deposited: "100" }, + ]); + + const result = await userService.getUserPortfolioAllocation(TEST_ADDRESS); + + expect(result.allocations).toEqual([ + { category: "Real Estate", deposited: "600", percentage: 60 }, + { category: "Treasury", deposited: "300", percentage: 30 }, + { category: "Uncategorized", deposited: "100", percentage: 10 }, + ]); + + const totalPercentage = result.allocations.reduce((sum, a) => sum + a.percentage, 0); + expect(totalPercentage).toBeCloseTo(100, 10); + }); + + it("handles uneven splits while still summing to ~100", async () => { + vi.mocked(db.query).mockResolvedValueOnce([ + { category: "Real Estate", deposited: "1" }, + { category: "Treasury", deposited: "1" }, + { category: "Commodities", deposited: "1" }, + ]); + + const result = await userService.getUserPortfolioAllocation(TEST_ADDRESS); + const totalPercentage = result.allocations.reduce((sum, a) => sum + a.percentage, 0); + expect(totalPercentage).toBeCloseTo(100, 10); + }); +}); + +describe("UserService.getUserPortfolioDiversification (#777)", () => { + let userService: UserService; + + beforeEach(() => { + userService = new UserService(); + vi.clearAllMocks(); + }); + + it("returns a score of 0 for a user with a single position", async () => { + vi.mocked(db.query).mockResolvedValueOnce([ + { category: "Real Estate", deposited: "1000" }, + ]); + + const result = await userService.getUserPortfolioDiversification(TEST_ADDRESS); + + expect(result.score).toBe(0); + expect(result.vaultCount).toBe(1); + expect(result.categoryCount).toBe(1); + expect(result.herfindahlIndex).toBe(1); + }); + + it("returns a score close to 75 for equal deposits across four vaults", async () => { + vi.mocked(db.query).mockResolvedValueOnce([ + { category: "Real Estate", deposited: "250" }, + { category: "Treasury", deposited: "250" }, + { category: "Commodities", deposited: "250" }, + { category: "Private Credit", deposited: "250" }, + ]); + + const result = await userService.getUserPortfolioDiversification(TEST_ADDRESS); + + expect(result.score).toBe(75); + expect(result.vaultCount).toBe(4); + expect(result.categoryCount).toBe(4); + expect(result.herfindahlIndex).toBeCloseTo(0.25, 10); + }); + + it("returns zeroed-out values for a user with no positions", async () => { + vi.mocked(db.query).mockResolvedValueOnce([]); + const result = await userService.getUserPortfolioDiversification(TEST_ADDRESS); + expect(result).toEqual({ score: 0, vaultCount: 0, categoryCount: 0, herfindahlIndex: 0 }); + }); +}); diff --git a/backend/src/services/user.ts b/backend/src/services/user.ts index d795ffd4..d7c0645a 100644 --- a/backend/src/services/user.ts +++ b/backend/src/services/user.ts @@ -9,6 +9,9 @@ import type { PortfolioPnlPosition, IncomeForecastResponse, IncomeForecastMonth, + PortfolioAllocation, + PortfolioAllocationResponse, + PortfolioDiversification, } from "../types/index.js"; import { EventEmitter } from "node:events"; import { query } from "../db/index.js"; @@ -206,6 +209,78 @@ export class UserService { }; } + /** Portfolio allocation by RWA category, weighted by deposited amount (#776). */ + async getUserPortfolioAllocation(address: string): Promise { + const rows = await query<{ category: string; deposited: string }>( + `SELECT COALESCE(v.rwa_category, 'Uncategorized') AS category, + SUM(uvp.deposited)::text AS deposited + FROM user_vault_positions uvp + JOIN vaults v ON uvp.vault_id = v.id + WHERE uvp.user_address = $1 + GROUP BY COALESCE(v.rwa_category, 'Uncategorized') + ORDER BY SUM(uvp.deposited) DESC`, + [address], + ); + + if (rows.length === 0) { + return { allocations: [] }; + } + + const totalDeposited = rows.reduce( + (sum, row) => sum + BigInt(row.deposited || "0"), + BigInt(0), + ); + + const allocations: PortfolioAllocation[] = rows.map((row) => { + const deposited = BigInt(row.deposited || "0"); + const percentage = + totalDeposited > 0n ? (Number(deposited) / Number(totalDeposited)) * 100 : 0; + return { + category: row.category, + deposited: deposited.toString(), + percentage, + }; + }); + + return { allocations }; + } + + /** + * Portfolio diversification score derived from the Herfindahl-Hirschman + * Index over per-vault deposited shares — lower HHI (spread across more + * vaults) yields a higher score (#777). + */ + async getUserPortfolioDiversification(address: string): Promise { + const rows = await query<{ category: string; deposited: string }>( + `SELECT v.rwa_category AS category, uvp.deposited::text AS deposited + FROM user_vault_positions uvp + JOIN vaults v ON uvp.vault_id = v.id + WHERE uvp.user_address = $1 AND uvp.deposited::numeric > 0`, + [address], + ); + + const vaultCount = rows.length; + const categoryCount = new Set(rows.map((row) => row.category ?? "Uncategorized")).size; + + if (vaultCount === 0) { + return { score: 0, vaultCount: 0, categoryCount: 0, herfindahlIndex: 0 }; + } + + const totalDeposited = rows.reduce( + (sum, row) => sum + BigInt(row.deposited || "0"), + BigInt(0), + ); + + const herfindahlIndex = rows.reduce((sum, row) => { + const share = Number(BigInt(row.deposited || "0")) / Number(totalDeposited); + return sum + share * share; + }, 0); + + const score = Math.round((1 - herfindahlIndex) * 100 * 10) / 10; + + return { score, vaultCount, categoryCount, herfindahlIndex }; + } + async getUserPortfolioPnl(address: string): Promise { const positions = await query<{ user_address: string; diff --git a/backend/src/types/index.ts b/backend/src/types/index.ts index 5a512659..8b4f3652 100644 --- a/backend/src/types/index.ts +++ b/backend/src/types/index.ts @@ -178,3 +178,26 @@ export interface AnalyticsSummary { totalYieldDistributed: string; totalDepositors: number; } + +export interface TvlAggregate { + totalValueLocked: string; + activeVaultCount: number; + fundingVaultCount: number; +} + +export interface PortfolioAllocation { + category: string; + deposited: string; + percentage: number; +} + +export interface PortfolioAllocationResponse { + allocations: PortfolioAllocation[]; +} + +export interface PortfolioDiversification { + score: number; + vaultCount: number; + categoryCount: number; + herfindahlIndex: number; +} diff --git a/pr.md b/pr.md index fed5cf30..e494656b 100644 --- a/pr.md +++ b/pr.md @@ -1,39 +1,63 @@ -# Pull Request: Add vault search, name-check, trending, and new vaults endpoints +# Pull Request: Add GraphQL query limiting, public TVL, and portfolio analytics endpoints -This PR adds four new API endpoints for vault discovery and search. +This PR adds GraphQL query depth/complexity limiting and three new analytics endpoints. -Closes #640 -Closes #641 -Closes #642 -Closes #643 +Closes #774 +Closes #775 +Closes #776 +Closes #777 ## Issues Fixed -### 1. `GET /api/v1/vaults/search` (#640) -- Combined search endpoint accepting `q`, `category`, `state`, `sort`, `order`, `page`, `pageSize`. -- Filters are applied independently (AND logic) with full Zod validation. -- Text search (`q`) matches against `name`, `symbol`, and `rwa_name` (case-insensitive ILIKE). -- Category filter matches against `rwa_name`. -- Returns paginated vault list in the same shape as `GET /api/v1/vaults`. +### 1. GraphQL query depth and complexity limiting (#774) -### 2. `GET /api/v1/vaults/name-check` (#641) -- Accepts `name` query parameter, returns `{ "available": true | false }`. -- Case-insensitive check: `WHERE LOWER(name) = LOWER($1)`. -- Returns HTTP 400 if name is missing or under 3 characters. +Deep or complex GraphQL queries can cause excessive DB load. This adds validation-level +limits to the existing Apollo Server: -### 3. `GET /api/v1/vaults/trending` (#642) -- Returns top 10 vaults ordered by sum of deposited amounts in the last 24 hours. -- Includes `contractId`, `name`, `recentDepositVolume` (sum as string). -- Returns `[]` if no deposits occurred recently. +- Installed `graphql-depth-limit` and `graphql-query-complexity`. +- New `src/graphql/queryLimits.ts` exports `depthLimitRule` and `complexityLimitRule`, + wired into `ApolloServer`'s `validationRules`. +- Max depth: 7. Exceeding it returns `Query depth {n} exceeds maximum of 7`. +- Max complexity: 200, via a custom estimator where every field costs 1 and any + field whose type resolves to a list costs 10. Exceeding it returns + `Query complexity {n} exceeds maximum of 200`. -### 4. `GET /api/v1/vaults/new` (#643) -- Returns vaults created within the given number of days (1–30, default 7). -- Accepts `days` query param to adjust the window. -- Returns the same vault shape as `GET /api/v1/vaults`. +### 2. Public cross-vault TVL aggregate (#775) + +`GET /api/v1/admin/stats` includes platform-wide TVL but is admin-gated. Dashboards need +a public equivalent: + +- `GET /api/v1/analytics/tvl` returns `{ totalValueLocked, activeVaultCount, fundingVaultCount }`. +- `totalValueLocked` sums `total_assets` across all non-archived vaults; the two counts + are vaults in the `Active` and `Funding` states respectively. +- No authentication required — mounted on the existing public `analyticsRouter`. +- Response includes `Cache-Control: max-age=30`. + +### 3. Portfolio asset allocation breakdown (#776) + +- `GET /api/v1/users/:address/portfolio/allocation` returns + `{ allocations: [{ category, deposited, percentage }] }`. +- Groups a user's positions by `vaults.rwa_category` (falling back to `"Uncategorized"`), + summing `deposited` per category. +- `percentage` is left unrounded (`categoryDeposited / totalDeposited * 100`) so that + percentages across categories sum to 100 within floating-point precision. +- Returns `{ allocations: [] }` for a user with no positions. + +### 4. Portfolio diversification score (#777) + +- `GET /api/v1/users/:address/portfolio/diversification` returns + `{ score, vaultCount, categoryCount, herfindahlIndex }`. +- `herfindahlIndex` is the sum of squared per-vault deposit shares — lower means more + diversified. +- `score = (1 - herfindahlIndex) * 100`, rounded to one decimal place. +- A user with a single position gets `score: 0`; a user with equal deposits across + four vaults gets `score: 75`. ## Verification -- `npm run lint` — clean (0 errors, 0 warnings) -- `npm run build` — success -- `npm run test` — all unit tests pass (2 pre-existing E2E failures require database) -- New routes registered before `/:contractId` to avoid Express route conflicts +- `npx tsc --noEmit` — clean +- New tests added: `src/graphql/queryLimits.test.ts`, `src/api/controllers/analytics.test.ts`, + `src/services/user.portfolio-analytics.test.ts` +- `npx vitest run` — all tests pass except two pre-existing, unrelated flakes + (`src/services/indexer.test.ts`, `src/api/controllers/admin.test.ts`), both confirmed + present on `main` prior to this change and passing when run in isolation