Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions backend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
81 changes: 81 additions & 0 deletions backend/src/api/controllers/analytics.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
37 changes: 36 additions & 1 deletion backend/src/api/controllers/analytics.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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);
}
}
30 changes: 30 additions & 0 deletions backend/src/api/controllers/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
3 changes: 2 additions & 1 deletion backend/src/api/routes/analytics.ts
Original file line number Diff line number Diff line change
@@ -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);
12 changes: 12 additions & 0 deletions backend/src/api/routes/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import {
getUserKyc,
getUserKycHistory,
getUserPortfolio,
getUserPortfolioAllocation,
getUserPortfolioDiversification,
getUserPortfolioPnl,
getUserShareHistory,
getUserYieldHistory,
Expand Down Expand Up @@ -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),
Expand Down
4 changes: 4 additions & 0 deletions backend/src/graphql/apolloServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -17,6 +18,9 @@ export const apolloServer = new ApolloServer<GraphQLContext>({
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()
Expand Down
92 changes: 92 additions & 0 deletions backend/src/graphql/queryLimits.test.ts
Original file line number Diff line number Diff line change
@@ -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}`,
);
});
});
Loading
Loading