diff --git a/backend/package-lock.json b/backend/package-lock.json index 14a003fa..2ab334b1 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -18,6 +18,7 @@ "express": "^4.21.0", "express-rate-limit": "^8.5.2", "helmet": "8.0.0", + "ioredis": "^5.4.2", "pg": "^8.13.0", "pino": "^9.5.0", "pino-http": "^10.3.0", @@ -372,7 +373,6 @@ "version": "8.59.0", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.59.0", "@typescript-eslint/types": "8.59.0", @@ -590,7 +590,6 @@ "version": "8.16.0", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -956,7 +955,6 @@ "version": "9.39.4", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -1646,7 +1644,6 @@ "version": "4.0.4", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -1870,7 +1867,6 @@ "version": "5.9.3", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -2597,6 +2593,12 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@ioredis/commands": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.10.0.tgz", + "integrity": "sha512-UmeW7z4LfctwoQ5wkhVzgq8tXkreED2xZGpX+Bg+zA+WJFZCT6c062AfCK/Dfk81xZnnwdhJCUMkitihRaoC2Q==", + "license": "MIT" + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "dev": true, @@ -3309,7 +3311,6 @@ "version": "8.60.0", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.60.0", "@typescript-eslint/types": "8.60.0", @@ -3637,7 +3638,6 @@ "version": "8.16.0", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3959,6 +3959,15 @@ "node": ">= 16" } }, + "node_modules/cluster-key-slot": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.1.tgz", + "integrity": "sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/color-convert": { "version": "2.0.1", "dev": true, @@ -4139,6 +4148,15 @@ "node": ">=0.4.0" } }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, "node_modules/depd": { "version": "2.0.0", "license": "MIT", @@ -4309,7 +4327,6 @@ "version": "9.39.4", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -4477,7 +4494,6 @@ "node_modules/express": { "version": "4.22.2", "license": "MIT", - "peer": true, "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", @@ -5000,6 +5016,28 @@ "version": "2.0.4", "license": "ISC" }, + "node_modules/ioredis": { + "version": "5.11.1", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.11.1.tgz", + "integrity": "sha512-ehuGcf94bQXhfagULNXrJdfnWO38v070jxSx/qE87Kjzmu2fU7ro5EFAb+OPituLqgfyuQaym5DlrNydW2sJ9A==", + "license": "MIT", + "dependencies": { + "@ioredis/commands": "1.10.0", + "cluster-key-slot": "1.1.1", + "debug": "4.4.3", + "denque": "2.1.0", + "redis-errors": "1.2.0", + "redis-parser": "3.0.0", + "standard-as-callback": "2.1.0" + }, + "engines": { + "node": ">=12.22.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ioredis" + } + }, "node_modules/ip-address": { "version": "10.2.0", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", @@ -5427,7 +5465,6 @@ "node_modules/pg": { "version": "8.21.0", "license": "MIT", - "peer": true, "dependencies": { "pg-connection-string": "^2.13.0", "pg-pool": "^3.14.0", @@ -5507,7 +5544,6 @@ "version": "4.0.4", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -5792,6 +5828,27 @@ "node": ">= 12.13.0" } }, + "node_modules/redis-errors": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", + "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/redis-parser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz", + "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==", + "license": "MIT", + "dependencies": { + "redis-errors": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/resolve-from": { "version": "4.0.0", "dev": true, @@ -6103,6 +6160,12 @@ "dev": true, "license": "MIT" }, + "node_modules/standard-as-callback": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", + "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==", + "license": "MIT" + }, "node_modules/statuses": { "version": "2.0.2", "license": "MIT", @@ -6365,7 +6428,6 @@ "version": "5.9.3", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -6437,7 +6499,6 @@ "version": "5.4.21", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", @@ -7102,7 +7163,6 @@ "node_modules/zod": { "version": "3.25.76", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/backend/src/api/controllers/admin.test.ts b/backend/src/api/controllers/admin.test.ts index 8e76a108..cc2b65bd 100644 --- a/backend/src/api/controllers/admin.test.ts +++ b/backend/src/api/controllers/admin.test.ts @@ -1,6 +1,27 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; +import supertest from "supertest"; +import { createHash } from "crypto"; vi.mock("../../db/index.js", () => ({ query: vi.fn() })); +vi.mock("../../services/indexerSingleton.js", () => ({ + indexer: { + isRunning: vi.fn().mockReturnValue(false), + getLastIndexedLedger: vi.fn().mockResolvedValue(0), + getLastTickAt: vi.fn().mockReturnValue(null), + getEventsIndexedCount: vi.fn().mockResolvedValue(0), + queueBackfill: vi.fn().mockResolvedValue(undefined), + }, +})); +vi.mock("../../services/vault.js", () => ({ + VaultService: vi.fn().mockImplementation(() => ({ + listArchivedVaults: vi.fn().mockResolvedValue([]), + getVault: vi.fn().mockResolvedValue(null), + })), +})); +vi.mock("../../services/stellar.js", () => ({ + readTotalSupply: vi.fn().mockResolvedValue(0n), +})); +vi.mock("pino-http", () => ({ pinoHttp: () => (_req: any, _res: any, next: any) => next() })); async function getTestContext() { const { query } = await import("../../db/index.js"); @@ -8,10 +29,22 @@ async function getTestContext() { return { query: query as ReturnType, getAdminStats }; } +async function getApp() { + const { createApp } = await import("../../app.js"); + return createApp(); +} + +/** Hash an API key the same way the auth middleware does */ +function hashKey(plaintext: string): string { + return createHash("sha256").update(plaintext).digest("hex"); +} + describe("Admin Controller", () => { beforeEach(() => { vi.clearAllMocks(); }); + + // ── Unit tests (controller function directly) ───────────────────────────── describe("getAdminStats", () => { it("returns vault/user/epoch counts and TVL", async () => { const { query, getAdminStats } = await getTestContext(); @@ -33,4 +66,66 @@ describe("Admin Controller", () => { expect(res.json).toHaveBeenCalledWith({ vaultCount: 2, userCount: 42, totalValueLocked: "12345", epochCount: 3 }); }); }); + + // ── Integration tests: GET /api/v1/admin/stats (Issue #692) ────────────── + describe("GET /api/v1/admin/stats", () => { + const VALID_KEY = "test-admin-api-key-12345"; + + beforeEach(async () => { + const { query } = await import("../../db/index.js"); + const mockQuery = query as ReturnType; + mockQuery.mockReset(); + }); + + it("returns 401 when the Authorization header is missing", async () => { + const app = await getApp(); + const res = await supertest(app).get("/api/v1/admin/stats"); + expect(res.status).toBe(401); + expect(res.body).toMatchObject({ error: "Unauthorized" }); + }); + + it("returns 403 when the API key is invalid", async () => { + const { query } = await import("../../db/index.js"); + const mockQuery = query as ReturnType; + // auth middleware queries api_keys — return empty = key not found + mockQuery.mockResolvedValue([]); + + const app = await getApp(); + const res = await supertest(app) + .get("/api/v1/admin/stats") + .set("Authorization", "Bearer not-a-real-key"); + + expect(res.status).toBe(403); + expect(res.body).toMatchObject({ error: "Forbidden" }); + }); + + it("returns 200 with correct vaultCount and userCount for a valid admin key and seeded DB", async () => { + const { query } = await import("../../db/index.js"); + const mockQuery = query as ReturnType; + + // auth middleware: api_keys lookup → match the hashed key + mockQuery.mockResolvedValueOnce([{ id: 1, role: "admin", label: "test" }]); + // getAdminStats: vaultCount + mockQuery.mockResolvedValueOnce([{ count: "3" }]); + // getAdminStats: userCount + mockQuery.mockResolvedValueOnce([{ count: "7" }]); + // getAdminStats: totalValueLocked + mockQuery.mockResolvedValueOnce([{ total: "9999999" }]); + // getAdminStats: epochCount + mockQuery.mockResolvedValueOnce([{ count: "5" }]); + + const app = await getApp(); + const res = await supertest(app) + .get("/api/v1/admin/stats") + .set("Authorization", `Bearer ${VALID_KEY}`); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ + vaultCount: 3, + userCount: 7, + totalValueLocked: "9999999", + epochCount: 5, + }); + }); + }); }); diff --git a/backend/src/api/controllers/users.ts b/backend/src/api/controllers/users.ts index 70a26291..a7992ab2 100644 --- a/backend/src/api/controllers/users.ts +++ b/backend/src/api/controllers/users.ts @@ -147,6 +147,11 @@ export async function getUserKycHistory(req: Request, res: Response, next: NextF }); res.json({ data, total, page, pageSize }); + } catch (err) { + next(err); + } +} + export async function getKycBatch(req: Request, res: Response, next: NextFunction) { try { const { addresses, vaultId } = req.body as { addresses: string[]; vaultId: string }; diff --git a/backend/src/api/controllers/vaults.ts b/backend/src/api/controllers/vaults.ts index 47384d26..f36e92d6 100644 --- a/backend/src/api/controllers/vaults.ts +++ b/backend/src/api/controllers/vaults.ts @@ -402,26 +402,6 @@ export async function getVaultHolders(req: Request, res: Response, next: NextFun } } -/** - * GET /api/v1/vaults/:contractId/operators - * - * Returns the current active operators for a vault. - */ -export async function getVaultOperators(req: Request, res: Response, next: NextFunction) { - try { - const vault = await vaultService.getVault(String(req.params["contractId"])); - if (!vault) { - res.status(404).json({ error: "NotFound", message: "Vault not found" }); - return; - } - const operators = await vaultService.listVaultOperators(String(req.params["contractId"])); - setCacheHeaders(res); - res.json(operators); - } catch (err) { - next(err); - } -} - /** * GET /api/v1/vaults/:contractId/roles * diff --git a/backend/src/api/routes/users.ts b/backend/src/api/routes/users.ts index 9b201eb5..9d23c3db 100644 --- a/backend/src/api/routes/users.ts +++ b/backend/src/api/routes/users.ts @@ -55,6 +55,8 @@ const yieldHistoryQuerySchema = z.object({ const kycHistoryQuerySchema = z.object({ page: z.coerce.number().int().min(1).default(1), pageSize: z.coerce.number().int().min(1).default(20).transform((v) => Math.min(v, 50)), +}); + const shareHistoryQuerySchema = z.object({ vaultId: z.string().length(56).regex(/^C[A-Z2-7]{55}$/).optional(), }); @@ -87,6 +89,8 @@ usersRouter.get( validateParams(addressParamSchema), validateQuery(kycHistoryQuerySchema), getUserKycHistory, +); +usersRouter.get( "/:address/share-history", validateParams(addressParamSchema), validateQuery(shareHistoryQuerySchema), diff --git a/backend/src/services/indexer.test.ts b/backend/src/services/indexer.test.ts index afc1c8ae..cca627be 100644 --- a/backend/src/services/indexer.test.ts +++ b/backend/src/services/indexer.test.ts @@ -583,6 +583,9 @@ describe("parseUnpausedEvent", () => { it("returns null for malformed input", () => { expect(parseUnpausedEvent(null)).toBeNull(); expect(parseUnpausedEvent({})).toBeNull(); + }); +}); + // ── Issue #611: parseKycVerifiedEvent ───────────────────────────────────────── describe("parseKycVerifiedEvent", () => { diff --git a/backend/src/services/indexer.ts b/backend/src/services/indexer.ts index ec6284d9..d49102d6 100644 --- a/backend/src/services/indexer.ts +++ b/backend/src/services/indexer.ts @@ -531,20 +531,6 @@ export class Indexer { return; } - const opAdded = parseOpAddEvent(event); - if (opAdded) { - await this.handleOperatorAdded(event.contractId ?? "", opAdded); - await this.recordEvent(event, "op_add"); - return; - } - - const opRemoved = parseOpRemEvent(event); - if (opRemoved) { - await this.handleOperatorRemoved(event.contractId ?? "", opRemoved); - await this.recordEvent(event, "op_rem"); - return; - } - const zkmeUpd = parseZkmeVerifierUpdatedEvent(event); if (zkmeUpd) { await this.handleZkmeVerifierUpdated(event.contractId ?? "", zkmeUpd); @@ -565,6 +551,10 @@ export class Indexer { event.contractId ?? "", JSON.stringify({ user: kycSet.user, verified: kycSet.verified, timestamp: Number(kycSet.timestamp) }), ], + ); + return; + } + const paused = parsePausedEvent(event); if (paused) { await this.handlePauseState(event.contractId ?? "", true); @@ -576,6 +566,9 @@ export class Indexer { if (unpaused) { await this.handlePauseState(event.contractId ?? "", false); await this.recordEvent(event, "unpaused"); + return; + } + const kycUpdate = parseKycVerifiedEvent(event); if (kycUpdate) { await this.userService.upsertUser(kycUpdate.user, kycUpdate.verified); @@ -889,6 +882,8 @@ export class Indexer { [paused, contractId], ); logger.info({ contractId, paused }, `Processed vault ${paused ? "paused" : "unpaused"} event`); + } + private async handleOperatorAdded( contractId: string, event: ParsedOperatorAddedEvent, @@ -1770,6 +1765,11 @@ export function parseYieldClaimedPartialEvent(rawEvent: unknown): ParsedYieldCla // ── #604: parseOpAddEvent / parseOpRemEvent ──────────────────────────────────── export interface ParsedOpAddEvent { + caller: string; + operator: string; + timestamp: bigint; +} + // ── Issue #606: parsePausedEvent / parseUnpausedEvent ───────────────────────── export interface ParsedPausedEvent { @@ -1792,6 +1792,11 @@ export function parsePausedEvent(rawEvent: any): ParsedPausedEvent | null { } if (eventName !== "paused" && eventName !== "v_pause") return null; return { contractId: String(rawEvent?.contractId ?? "") }; + } catch { + return null; + } +} + // ── Issue #593: operator events ───────────────────────────────────────────── export interface ParsedOperatorAddedEvent { @@ -1801,6 +1806,9 @@ export interface ParsedOperatorAddedEvent { } export function parseOpAddEvent(rawEvent: unknown): ParsedOpAddEvent | null { + return parseOperatorAddedEvent(rawEvent); +} + export function parseOperatorAddedEvent(rawEvent: unknown): ParsedOperatorAddedEvent | null { try { if (!rawEvent || typeof rawEvent !== "object") return null; @@ -1842,6 +1850,9 @@ export interface ParsedOpRemEvent { } export function parseOpRemEvent(rawEvent: unknown): ParsedOpRemEvent | null { + return parseOperatorRemovedEvent(rawEvent); +} + export interface ParsedOperatorRemovedEvent { caller: string; operator: string; @@ -1875,12 +1886,6 @@ export function parseOperatorRemovedEvent(rawEvent: unknown): ParsedOperatorRemo const caller = String(scValToNative(parsedTopics[1]) ?? ""); const operator = String(scValToNative(parsedTopics[2]) ?? ""); - const data = scValToNative(parsedValue as xdr.ScVal); - const arr = Array.isArray(data) ? data : Object.values((data as Record) ?? {}); - const timestamp = decodeBigInt(arr[0]); - - return { caller, operator, timestamp }; - const data = scValToNative(parsedValue as xdr.ScVal); const arr = Array.isArray(data) ? data : Object.values((data as Record) ?? {}); const timestamp = decodeBigInt(arr[0] ?? 0n); @@ -1901,14 +1906,6 @@ export interface ParsedZkmeVerifierUpdatedEvent { } export function parseZkmeVerifierUpdatedEvent(rawEvent: unknown): ParsedZkmeVerifierUpdatedEvent | null { -// ── Issue #594: role events ───────────────────────────────────────────────── - -export interface ParsedRoleGrantedEvent { - userAddress: string; - role: string; -} - -export function parseRoleGrantedEvent(rawEvent: unknown): ParsedRoleGrantedEvent | null { try { if (!rawEvent || typeof rawEvent !== "object") return null; const ev = rawEvent as Record; @@ -1939,6 +1936,40 @@ export function parseRoleGrantedEvent(rawEvent: unknown): ParsedRoleGrantedEvent const newVerifier = String(arr[1] ?? ""); return { caller, oldVerifier, newVerifier }; + } catch { + return null; + } +} + +// ── Issue #594: role events ───────────────────────────────────────────────── + +export interface ParsedRoleGrantedEvent { + userAddress: string; + role: string; +} + +export function parseRoleGrantedEvent(rawEvent: unknown): ParsedRoleGrantedEvent | null { + try { + if (!rawEvent || typeof rawEvent !== "object") return null; + const ev = rawEvent as Record; + const topics = (ev["topic"] ?? ev["topics"]) as unknown[] | undefined; + const value = ev["value"] ?? ev["data"]; + + if (!Array.isArray(topics) || topics.length < 2 || value == null) return null; + + const parsedTopics = topics.map((t) => + typeof t === "string" ? xdr.ScVal.fromXDR(t, "base64") : (t as xdr.ScVal), + ); + const parsedValue = typeof value === "string" + ? xdr.ScVal.fromXDR(value, "base64") + : value; + + let eventName: string; + try { + eventName = String(scValToNative(parsedTopics[0]) ?? ""); + } catch { + return null; + } if (eventName !== "role_grt") return null; const userAddress = String(scValToNative(parsedTopics[1]) ?? ""); @@ -1963,6 +1994,41 @@ export interface ParsedKycSetEvent { } export function parseKycSetEvent(rawEvent: unknown): ParsedKycSetEvent | null { + try { + if (!rawEvent || typeof rawEvent !== "object") return null; + const ev = rawEvent as Record; + const topics = (ev["topic"] ?? ev["topics"]) as unknown[] | undefined; + const value = ev["value"] ?? ev["data"]; + + if (!Array.isArray(topics) || topics.length < 2 || value == null) return null; + + const parsedTopics = topics.map((t) => + typeof t === "string" ? xdr.ScVal.fromXDR(t, "base64") : (t as xdr.ScVal), + ); + const parsedValue = typeof value === "string" + ? xdr.ScVal.fromXDR(value, "base64") + : value; + + let eventName: string; + try { + eventName = String(scValToNative(parsedTopics[0]) ?? ""); + } catch { + return null; + } + if (eventName !== "kyc_set") return null; + + const user = String(scValToNative(parsedTopics[1]) ?? ""); + const data = scValToNative(parsedValue as xdr.ScVal); + const arr = Array.isArray(data) ? data : Object.values((data as Record) ?? {}); + const verified = Boolean(arr[0]); + const timestamp = decodeBigInt(arr[1] ?? 0n); + + return { user, verified, timestamp }; + } catch { + return null; + } +} + export interface ParsedUnpausedEvent { contractId: string; } @@ -1983,6 +2049,11 @@ export function parseUnpausedEvent(rawEvent: any): ParsedUnpausedEvent | null { } if (eventName !== "unpaused" && eventName !== "v_unpause") return null; return { contractId: String(rawEvent?.contractId ?? "") }; + } catch { + return null; + } +} + export interface ParsedRoleRevokedEvent { userAddress: string; role: string; @@ -2010,15 +2081,6 @@ export function parseRoleRevokedEvent(rawEvent: unknown): ParsedRoleRevokedEvent } catch { return null; } - if (eventName !== "kyc_set") return null; - - const user = String(scValToNative(parsedTopics[1]) ?? ""); - const data = scValToNative(parsedValue as xdr.ScVal); - const arr = Array.isArray(data) ? data : Object.values((data as Record) ?? {}); - const verified = Boolean(arr[0]); - const timestamp = decodeBigInt(arr[1] ?? 0n); - - return { user, verified, timestamp }; if (eventName !== "role_rvk") return null; const userAddress = String(scValToNative(parsedTopics[1]) ?? ""); diff --git a/backend/src/services/vault.ts b/backend/src/services/vault.ts index d5b86b42..8fc23622 100644 --- a/backend/src/services/vault.ts +++ b/backend/src/services/vault.ts @@ -56,6 +56,17 @@ function computeFundingProgress(totalAssets: string, fundingTarget: string | nul return Math.min(100, (parseFloat(totalAssets) / target) * 100); } +function extractAddressFromScVal(topic: unknown): string { + if (typeof topic === "string") { + try { + return String(scValToNative(xdr.ScVal.fromXDR(topic, "base64"))); + } catch { + return ""; + } + } + return ""; +} + function mapVaultRow(row: VaultRow): Vault { return { id: row.id, @@ -227,40 +238,6 @@ export class VaultService { total = parseInt(countResult[0]?.count ?? "0", 10); } - // Build WHERE clause if state filter is provided - const whereClause = state ? "WHERE v.state = $3" : ""; - const params: any[] = [pageSize, offset]; - if (state) params.push(state); - - // Query vaults with pagination. - // COALESCE(v.total_assets, '0') guarantees every vault item in the response - // carries a non-null totalAssets string, satisfying issue #499. - const vaults = await query( - `SELECT v.id, v.contract_id, v.factory_id, v.asset, v.name, v.symbol, v.state, - v.total_assets, v.total_supply, v.created_at, v.updated_at, - v.funding_target, v.funding_deadline, v.min_deposit, v.max_deposit_per_user, - v.zkme_verifier_address, - COALESCE(( - SELECT COUNT(*)::int - FROM user_vault_positions uvp - WHERE uvp.vault_id = v.id AND uvp.shares > 0 - ), 0) AS depositor_count - FROM vaults v - ${whereClause} - ORDER BY v.${sortColumn} ${sortDirection} - LIMIT $1 OFFSET $2`, - params, - ); - - // Get total count - const countResult = await query<{ count: string }>( - `SELECT COUNT(*) as count - FROM vaults v - ${state ? "WHERE v.state = $1" : ""}`, - state ? [state] : [], - ); - const total = parseInt(countResult[0]?.count ?? "0", 10); - // Map database rows to Vault type const data: Vault[] = vaults.map(mapVaultRow); @@ -811,18 +788,6 @@ export class VaultService { return { data, total, page, pageSize }; } -} - -function extractAddressFromScVal(topic: unknown): string { - if (typeof topic === "string") { - try { - return String(scValToNative(xdr.ScVal.fromXDR(topic, "base64"))); - } catch { - return ""; - } - } - return ""; -} // ── Issue #640 / #646: Combined search with optional fuzzy matching ────────── async searchVaults(opts: {