From ffd9cbe16edcaaa583e25ef3a785c694121aac1c Mon Sep 17 00:00:00 2001 From: Ada-Girly881 Date: Fri, 28 Aug 2026 02:50:56 +0100 Subject: [PATCH] feat: add bridge comparison report and analytics controls - Add bridge comparison report service and API (#1149) - Add transaction address labeling service and API (#1152) - Add historical liquidity heatmap export service and API (#1150) - Add chart data sampling controls service and API (#1151) - Add DB migration for address labels table - Register new routes in analytics, bridge, and data route groups - Add unit tests for all four new services --- .../src/api/routes/addressLabels.routes.ts | 151 +++++++++ .../routes/bridgeComparisonReport.routes.ts | 44 +++ .../routes/chartSamplingControls.routes.ts | 106 +++++++ .../routes/liquidityHeatmapExport.routes.ts | 63 ++++ .../routes/route-groups/analytics-routes.ts | 14 + .../api/routes/route-groups/bridge-routes.ts | 7 + .../api/routes/route-groups/data-routes.ts | 5 + ...120000_bridge_analytics_reporting_suite.ts | 62 ++++ .../src/database/models/addressLabel.model.ts | 120 +++++++ backend/src/services/addressLabel.service.ts | 252 +++++++++++++++ .../bridgeComparisonReport.service.ts | 184 +++++++++++ .../services/chartSamplingControls.service.ts | 297 ++++++++++++++++++ .../liquidityHeatmapExport.service.ts | 160 ++++++++++ .../services/addressLabel.service.test.ts | 156 +++++++++ .../bridgeComparisonReport.service.test.ts | 104 ++++++ .../chartSamplingControls.service.test.ts | 111 +++++++ .../liquidityHeatmapExport.service.test.ts | 60 ++++ 17 files changed, 1896 insertions(+) create mode 100644 backend/src/api/routes/addressLabels.routes.ts create mode 100644 backend/src/api/routes/bridgeComparisonReport.routes.ts create mode 100644 backend/src/api/routes/chartSamplingControls.routes.ts create mode 100644 backend/src/api/routes/liquidityHeatmapExport.routes.ts create mode 100644 backend/src/database/migrations/20260828120000_bridge_analytics_reporting_suite.ts create mode 100644 backend/src/database/models/addressLabel.model.ts create mode 100644 backend/src/services/addressLabel.service.ts create mode 100644 backend/src/services/bridgeComparisonReport.service.ts create mode 100644 backend/src/services/chartSamplingControls.service.ts create mode 100644 backend/src/services/liquidityHeatmapExport.service.ts create mode 100644 backend/tests/services/addressLabel.service.test.ts create mode 100644 backend/tests/services/bridgeComparisonReport.service.test.ts create mode 100644 backend/tests/services/chartSamplingControls.service.test.ts create mode 100644 backend/tests/services/liquidityHeatmapExport.service.test.ts diff --git a/backend/src/api/routes/addressLabels.routes.ts b/backend/src/api/routes/addressLabels.routes.ts new file mode 100644 index 00000000..50876e51 --- /dev/null +++ b/backend/src/api/routes/addressLabels.routes.ts @@ -0,0 +1,151 @@ +import type { FastifyInstance } from "fastify"; +import { authMiddleware } from "../middleware/auth.js"; +import { addressLabelService } from "../../services/addressLabel.service.js"; +import { logger } from "../../utils/logger.js"; + +/** + * Transaction address labeling routes (#1152). + * + * Registered at prefix: /api/v1/address-labels + */ +export async function addressLabelsRoutes(server: FastifyInstance) { + const requireWrite = authMiddleware({ requiredScopes: ["address-labels:write"] }); + const requireRead = authMiddleware({ requiredScopes: ["address-labels:read"] }); + + // GET / — search/list labels + server.get<{ + Querystring: { + category?: string; + chain?: string; + query?: string; + includeInactive?: string; + limit?: string; + offset?: string; + }; + }>("/", { preHandler: requireRead }, async (request, reply) => { + try { + const { category, chain, query, includeInactive, limit, offset } = request.query; + const labels = await addressLabelService.searchLabels({ + category, + chain, + query, + includeInactive: includeInactive === "true", + limit: limit ? Number(limit) : undefined, + offset: offset ? Number(offset) : undefined, + }); + return reply.code(200).send({ labels }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + logger.error({ err }, "Failed to search address labels"); + return reply.code(400).send({ error: message }); + } + }); + + // GET /lookup/:address — single address lookup + server.get<{ Params: { address: string }; Querystring: { chain?: string } }>( + "/lookup/:address", + { preHandler: requireRead }, + async (request, reply) => { + const label = await addressLabelService.lookupAddress( + request.params.address, + request.query.chain ?? "stellar" + ); + if (!label) { + return reply.code(404).send({ error: "No label found for this address" }); + } + return reply.code(200).send({ label }); + } + ); + + // POST /bulk-lookup — enrich a batch of addresses (e.g. a transactions page) + server.post<{ Body: { addresses?: string[]; chain?: string } }>( + "/bulk-lookup", + { preHandler: requireRead }, + async (request, reply) => { + const { addresses, chain } = request.body ?? {}; + if (!Array.isArray(addresses) || addresses.length === 0) { + return reply.code(400).send({ error: "addresses must be a non-empty array" }); + } + if (addresses.length > 500) { + return reply.code(400).send({ error: "addresses cannot exceed 500 entries per request" }); + } + + const labelsByAddress = await addressLabelService.lookupAddresses(addresses, chain); + return reply.code(200).send({ labels: Object.fromEntries(labelsByAddress) }); + } + ); + + // POST / — create a label + server.post<{ + Body: { + address?: string; + chain?: string; + label?: string; + category?: string; + notes?: string | null; + confidence?: number; + source?: string; + }; + }>("/", { preHandler: requireWrite }, async (request, reply) => { + const actorId = request.tenantContext?.actorId ?? "unknown"; + try { + const body = request.body ?? {}; + if (!body.address || !body.label) { + return reply.code(400).send({ error: "address and label are required" }); + } + const created = await addressLabelService.createLabel({ + address: body.address, + chain: body.chain, + label: body.label, + category: body.category, + notes: body.notes, + confidence: body.confidence, + source: body.source, + performedBy: actorId, + }); + return reply.code(201).send({ label: created }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return reply.code(400).send({ error: message }); + } + }); + + // PATCH /:id — update a label + server.patch<{ + Params: { id: string }; + Body: { + label?: string; + category?: string; + notes?: string | null; + confidence?: number; + isActive?: boolean; + }; + }>("/:id", { preHandler: requireWrite }, async (request, reply) => { + const actorId = request.tenantContext?.actorId ?? "unknown"; + try { + const updated = await addressLabelService.updateLabel(request.params.id, request.body ?? {}, actorId); + return reply.code(200).send({ label: updated }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + const status = message.includes("not found") ? 404 : 400; + return reply.code(status).send({ error: message }); + } + }); + + // DELETE /:id — remove a label + server.delete<{ Params: { id: string } }>( + "/:id", + { preHandler: requireWrite }, + async (request, reply) => { + const actorId = request.tenantContext?.actorId ?? "unknown"; + try { + await addressLabelService.deleteLabel(request.params.id, actorId); + return reply.code(204).send(); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + const status = message.includes("not found") ? 404 : 400; + return reply.code(status).send({ error: message }); + } + } + ); +} diff --git a/backend/src/api/routes/bridgeComparisonReport.routes.ts b/backend/src/api/routes/bridgeComparisonReport.routes.ts new file mode 100644 index 00000000..0c5a6efa --- /dev/null +++ b/backend/src/api/routes/bridgeComparisonReport.routes.ts @@ -0,0 +1,44 @@ +import type { FastifyInstance } from "fastify"; +import { bridgeComparisonReportService } from "../../services/bridgeComparisonReport.service.js"; +import { logger } from "../../utils/logger.js"; + +/** + * Bridge comparison report routes (#1149). + * + * Registered at prefix: /api/v1/bridge-comparison-report + */ +export async function bridgeComparisonReportRoutes(server: FastifyInstance) { + server.get<{ + Querystring: { + bridges?: string; + startDate?: string; + endDate?: string; + format?: "json" | "csv"; + }; + }>("/", async (request, reply) => { + try { + const { bridges, startDate, endDate, format } = request.query; + const bridgeNames = bridges + ? bridges + .split(",") + .map((b) => b.trim()) + .filter(Boolean) + : undefined; + + const dateRange = startDate || endDate ? { startDate, endDate } : undefined; + const report = await bridgeComparisonReportService.generateReport(bridgeNames, dateRange); + + if (format === "csv") { + reply.header("Content-Type", "text/csv"); + reply.header("Content-Disposition", "attachment; filename=bridge-comparison-report.csv"); + return reply.code(200).send(bridgeComparisonReportService.toCsv(report)); + } + + return reply.code(200).send(report); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + logger.error({ err }, "Failed to generate bridge comparison report"); + return reply.code(500).send({ error: message }); + } + }); +} diff --git a/backend/src/api/routes/chartSamplingControls.routes.ts b/backend/src/api/routes/chartSamplingControls.routes.ts new file mode 100644 index 00000000..1a16deee --- /dev/null +++ b/backend/src/api/routes/chartSamplingControls.routes.ts @@ -0,0 +1,106 @@ +import type { FastifyInstance } from "fastify"; +import { authMiddleware } from "../middleware/auth.js"; +import { + chartSamplingControlsService, + type ChartDataPoint, + type SamplingStrategy, +} from "../../services/chartSamplingControls.service.js"; +import { logger } from "../../utils/logger.js"; + +const VALID_STRATEGIES: SamplingStrategy[] = ["lttb", "fixed_interval", "min_max", "nth_point"]; + +/** + * Chart data sampling controls routes (#1151). + * + * Registered at prefix: /api/v1/chart-sampling + */ +export async function chartSamplingControlsRoutes(server: FastifyInstance) { + const requireAdmin = authMiddleware({ requiredScopes: ["admin:sampling"] }); + + // POST /sample — one-off downsample of a provided series + server.post<{ + Body: { + points?: ChartDataPoint[]; + strategy?: SamplingStrategy; + maxPoints?: number; + profile?: string; + }; + }>("/sample", async (request, reply) => { + try { + const { points, strategy, maxPoints, profile } = request.body ?? {}; + if (!Array.isArray(points)) { + return reply.code(400).send({ error: "points must be an array of { timestamp, value }" }); + } + if (strategy && !VALID_STRATEGIES.includes(strategy)) { + return reply.code(400).send({ + error: `strategy must be one of: ${VALID_STRATEGIES.join(", ")}`, + }); + } + + const sampled = profile + ? await chartSamplingControlsService.sampleWithProfile(profile, points) + : chartSamplingControlsService.sampleSeries(points, { strategy, maxPoints }); + + return reply.code(200).send({ + originalCount: points.length, + sampledCount: sampled.length, + points: sampled, + }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + const status = message.includes("not found") ? 404 : 400; + return reply.code(status).send({ error: message }); + } + }); + + // GET /profiles — list saved sampling profiles + server.get("/profiles", async (_request, reply) => { + const profiles = await chartSamplingControlsService.listProfiles(); + return reply.code(200).send({ profiles }); + }); + + // POST /profiles — create a saved sampling profile (admin only) + server.post<{ + Body: { + name?: string; + description?: string; + strategy?: SamplingStrategy; + maxPoints?: number; + minIntervalSeconds?: number; + }; + }>("/profiles", { preHandler: requireAdmin }, async (request, reply) => { + const actorId = request.tenantContext?.actorId ?? "unknown"; + try { + const body = request.body ?? {}; + if (!body.name) { + return reply.code(400).send({ error: "name is required" }); + } + const profile = await chartSamplingControlsService.createProfile({ + name: body.name, + description: body.description, + strategy: body.strategy, + maxPoints: body.maxPoints, + minIntervalSeconds: body.minIntervalSeconds, + createdBy: actorId, + }); + return reply.code(201).send({ profile }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + logger.error({ err }, "Failed to create chart sampling profile"); + return reply.code(400).send({ error: message }); + } + }); + + // DELETE /profiles/:id — remove a saved sampling profile (admin only) + server.delete<{ Params: { id: string } }>( + "/profiles/:id", + { preHandler: requireAdmin }, + async (request, reply) => { + const deleted = await chartSamplingControlsService.deleteProfile(request.params.id); + if (!deleted) { + return reply.code(404).send({ error: "Sampling profile not found" }); + } + return reply.code(204).send(); + } + ); +} diff --git a/backend/src/api/routes/liquidityHeatmapExport.routes.ts b/backend/src/api/routes/liquidityHeatmapExport.routes.ts new file mode 100644 index 00000000..46a923ec --- /dev/null +++ b/backend/src/api/routes/liquidityHeatmapExport.routes.ts @@ -0,0 +1,63 @@ +import type { FastifyInstance } from "fastify"; +import { + liquidityHeatmapExportService, + type HeatmapInterval, +} from "../../services/liquidityHeatmapExport.service.js"; +import { logger } from "../../utils/logger.js"; + +/** + * Historical liquidity heatmap export routes (#1150). + * + * Registered at prefix: /api/v1/liquidity-heatmap + */ +export async function liquidityHeatmapExportRoutes(server: FastifyInstance) { + server.get<{ + Querystring: { + startDate?: string; + endDate?: string; + symbols?: string; + interval?: HeatmapInterval; + format?: "json" | "csv"; + }; + }>("/export", async (request, reply) => { + try { + const { startDate, endDate, symbols, interval, format } = request.query; + + if (!startDate || !endDate) { + return reply.code(400).send({ error: "startDate and endDate query parameters are required" }); + } + if (new Date(startDate).getTime() > new Date(endDate).getTime()) { + return reply.code(400).send({ error: "startDate must be before endDate" }); + } + if (interval && interval !== "hour" && interval !== "day") { + return reply.code(400).send({ error: "interval must be 'hour' or 'day'" }); + } + + const symbolList = symbols + ? symbols + .split(",") + .map((s) => s.trim().toUpperCase()) + .filter(Boolean) + : undefined; + + const heatmap = await liquidityHeatmapExportService.exportHeatmap({ + startDate, + endDate, + symbols: symbolList, + interval, + }); + + if (format === "csv") { + reply.header("Content-Type", "text/csv"); + reply.header("Content-Disposition", "attachment; filename=liquidity-heatmap.csv"); + return reply.code(200).send(liquidityHeatmapExportService.toCsv(heatmap)); + } + + return reply.code(200).send(heatmap); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + logger.error({ err }, "Failed to export liquidity heatmap"); + return reply.code(500).send({ error: message }); + } + }); +} diff --git a/backend/src/api/routes/route-groups/analytics-routes.ts b/backend/src/api/routes/route-groups/analytics-routes.ts index e55dbd44..baacb74f 100644 --- a/backend/src/api/routes/route-groups/analytics-routes.ts +++ b/backend/src/api/routes/route-groups/analytics-routes.ts @@ -8,6 +8,10 @@ import { performanceBaselineRoutes } from "../performanceBaseline.routes.js"; import { sorobanInvocationCostRoutes } from "../sorobanInvocationCost.routes.js"; import { correlationAnalysisRoutes } from "../correlationAnalysis.routes.js"; import { txFeeForecastHistoryRoutes } from "../txFeeForecastHistory.routes.js"; +// #1150 — Historical Liquidity Heatmap Export +import { liquidityHeatmapExportRoutes } from "../liquidityHeatmapExport.routes.js"; +// #1151 — Chart Data Sampling Controls +import { chartSamplingControlsRoutes } from "../chartSamplingControls.routes.js"; export async function registerAnalyticsRoutes(server: FastifyInstance): Promise { server.register(analyticsRoutes, { prefix: "/api/v1/analytics" }); @@ -31,4 +35,14 @@ export async function registerAnalyticsRoutes(server: FastifyInstance): Promise< server.register(txFeeForecastHistoryRoutes, { prefix: "/api/v1/analytics/fee-forecast", }); + + // #1150 — Historical Liquidity Heatmap Export + server.register(liquidityHeatmapExportRoutes, { + prefix: "/api/v1/liquidity-heatmap", + }); + + // #1151 — Chart Data Sampling Controls + server.register(chartSamplingControlsRoutes, { + prefix: "/api/v1/chart-sampling", + }); } diff --git a/backend/src/api/routes/route-groups/bridge-routes.ts b/backend/src/api/routes/route-groups/bridge-routes.ts index 435a3027..465d2c09 100644 --- a/backend/src/api/routes/route-groups/bridge-routes.ts +++ b/backend/src/api/routes/route-groups/bridge-routes.ts @@ -5,6 +5,8 @@ import { poolRoutes } from "../pools.routes.js"; import { crossChainVerificationRoutes } from "../crossChainVerification.routes.js"; import { transferSLARoutes } from "../transferSLA.routes.js"; import { sorobanBatchPlannerRoutes } from "../sorobanBatchPlanner.routes.js"; +// #1149 — Bridge Comparison Report +import { bridgeComparisonReportRoutes } from "../bridgeComparisonReport.routes.js"; export async function registerBridgeRoutes(server: FastifyInstance): Promise { server.register(bridgesRoutes, { prefix: "/api/v1/bridges" }); @@ -17,4 +19,9 @@ export async function registerBridgeRoutes(server: FastifyInstance): Promise { server.register(transactionsRoutes, { prefix: "/api/v1/transactions" }); @@ -13,4 +15,7 @@ export async function registerDataRoutes(server: FastifyInstance): Promise server.register(archivedDataBrowserRoutes, { prefix: "/api/v1/archive" }); server.register(freshnessRoutes, { prefix: "/api/v1/freshness" }); server.register(provenanceRoutes, { prefix: "/api/v1/provenance" }); + + // #1152 — Transaction Address Labeling Service + server.register(addressLabelsRoutes, { prefix: "/api/v1/address-labels" }); } diff --git a/backend/src/database/migrations/20260828120000_bridge_analytics_reporting_suite.ts b/backend/src/database/migrations/20260828120000_bridge_analytics_reporting_suite.ts new file mode 100644 index 00000000..3c218dcb --- /dev/null +++ b/backend/src/database/migrations/20260828120000_bridge_analytics_reporting_suite.ts @@ -0,0 +1,62 @@ +import type { Knex } from "knex"; + +/** + * Bridge analytics reporting suite. + * + * Adds persistence for: + * - `address_labels`: human-readable labels/categories attached to chain + * addresses (exchange, bridge contract, known attacker, etc.) used by the + * transaction address labeling service (#1152). + * - `chart_sampling_profiles`: reusable named downsampling configurations + * used by the chart data sampling controls (#1151) so dashboards can + * reference a saved strategy instead of re-specifying parameters per call. + */ +export async function up(knex: Knex): Promise { + await knex.schema.createTable("address_labels", (table) => { + table.uuid("id").primary().defaultTo(knex.raw("gen_random_uuid()")); + table.string("address", 128).notNullable(); + table.string("chain", 32).notNullable().defaultTo("stellar"); + table.string("label", 128).notNullable(); + // exchange | bridge_contract | contract | individual | suspicious | internal | other + table.string("category", 32).notNullable().defaultTo("other"); + table.text("notes").nullable(); + // 0-100 confidence that the label is accurate + table.integer("confidence").notNullable().defaultTo(100); + table.string("source", 64).notNullable().defaultTo("manual"); + table.string("created_by", 128).notNullable(); + table.boolean("is_active").notNullable().defaultTo(true); + table.timestamp("created_at", { useTz: true }).notNullable().defaultTo(knex.fn.now()); + table.timestamp("updated_at", { useTz: true }).notNullable().defaultTo(knex.fn.now()); + + table.unique(["address", "chain"], { indexName: "uq_address_labels_address_chain" }); + table.index(["category"], "idx_address_labels_category"); + table.index(["chain", "is_active"], "idx_address_labels_chain_active"); + + table.check( + "confidence >= 0 AND confidence <= 100", + [], + "chk_address_labels_confidence_range" + ); + }); + + await knex.schema.createTable("chart_sampling_profiles", (table) => { + table.uuid("id").primary().defaultTo(knex.raw("gen_random_uuid()")); + table.string("name", 128).notNullable().unique(); + table.string("description", 500).nullable(); + // lttb | fixed_interval | min_max | nth_point + table.string("strategy", 32).notNullable().defaultTo("lttb"); + table.integer("max_points").notNullable().defaultTo(500); + table.integer("min_interval_seconds").nullable(); + table.boolean("enabled").notNullable().defaultTo(true); + table.string("created_by", 128).notNullable(); + table.timestamp("created_at", { useTz: true }).notNullable().defaultTo(knex.fn.now()); + table.timestamp("updated_at", { useTz: true }).notNullable().defaultTo(knex.fn.now()); + + table.check("max_points > 0 AND max_points <= 100000", [], "chk_sampling_profile_max_points"); + }); +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists("chart_sampling_profiles"); + await knex.schema.dropTableIfExists("address_labels"); +} diff --git a/backend/src/database/models/addressLabel.model.ts b/backend/src/database/models/addressLabel.model.ts new file mode 100644 index 00000000..52fb4848 --- /dev/null +++ b/backend/src/database/models/addressLabel.model.ts @@ -0,0 +1,120 @@ +import { getDatabase } from "../connection.js"; + +export type AddressLabelChain = "stellar" | "ethereum" | "polygon" | "avalanche" | "bsc" | "other"; + +export type AddressLabelCategory = + | "exchange" + | "bridge_contract" + | "contract" + | "individual" + | "suspicious" + | "internal" + | "other"; + +export interface AddressLabel { + id: string; + address: string; + chain: AddressLabelChain; + label: string; + category: AddressLabelCategory; + notes: string | null; + confidence: number; + source: string; + created_by: string; + is_active: boolean; + created_at: Date; + updated_at: Date; +} + +export interface CreateAddressLabelRow { + id: string; + address: string; + chain: AddressLabelChain; + label: string; + category: AddressLabelCategory; + notes?: string | null; + confidence?: number; + source?: string; + created_by: string; +} + +export class AddressLabelModel { + private db = getDatabase(); + private table = "address_labels"; + + async findByAddressChain(address: string, chain: string): Promise { + return this.db(this.table).where({ address, chain }).first(); + } + + async findById(id: string): Promise { + return this.db(this.table).where({ id }).first(); + } + + async findByAddresses(addresses: string[], chain?: string): Promise { + if (addresses.length === 0) return []; + const query = this.db(this.table).whereIn("address", addresses).andWhere("is_active", true); + if (chain) { + query.andWhere("chain", chain); + } + return query; + } + + async search(filters: { + category?: string; + chain?: string; + query?: string; + includeInactive?: boolean; + limit?: number; + offset?: number; + }): Promise { + const query = this.db(this.table).select("*"); + if (!filters.includeInactive) { + query.andWhere("is_active", true); + } + if (filters.category) { + query.andWhere("category", filters.category); + } + if (filters.chain) { + query.andWhere("chain", filters.chain); + } + if (filters.query) { + query.andWhere((builder: any) => { + builder.whereILike("address", `%${filters.query}%`).orWhereILike("label", `%${filters.query}%`); + }); + } + return query + .orderBy("updated_at", "desc") + .limit(filters.limit ?? 50) + .offset(filters.offset ?? 0); + } + + async create(data: CreateAddressLabelRow): Promise { + const [row] = await this.db(this.table) + .insert({ + ...data, + confidence: data.confidence ?? 100, + source: data.source ?? "manual", + is_active: true, + created_at: new Date(), + updated_at: new Date(), + }) + .returning("*"); + return row; + } + + async update( + id: string, + data: Partial> + ): Promise { + const [row] = await this.db(this.table) + .where({ id }) + .update({ ...data, updated_at: new Date() }) + .returning("*"); + return row; + } + + async delete(id: string): Promise { + const count = await this.db(this.table).where({ id }).del(); + return count > 0; + } +} diff --git a/backend/src/services/addressLabel.service.ts b/backend/src/services/addressLabel.service.ts new file mode 100644 index 00000000..5718422f --- /dev/null +++ b/backend/src/services/addressLabel.service.ts @@ -0,0 +1,252 @@ +import crypto from "crypto"; +import { + AddressLabelModel, + type AddressLabel, + type AddressLabelCategory, + type AddressLabelChain, +} from "../database/models/addressLabel.model.js"; +import { auditService } from "./audit.service.js"; +import { logger } from "../utils/logger.js"; + +const VALID_CHAINS: AddressLabelChain[] = ["stellar", "ethereum", "polygon", "avalanche", "bsc", "other"]; +const VALID_CATEGORIES: AddressLabelCategory[] = [ + "exchange", + "bridge_contract", + "contract", + "individual", + "suspicious", + "internal", + "other", +]; + +export interface CreateAddressLabelParams { + address: string; + chain?: string; + label: string; + category?: string; + notes?: string | null; + confidence?: number; + source?: string; + performedBy: string; +} + +export interface UpdateAddressLabelParams { + label?: string; + category?: string; + notes?: string | null; + confidence?: number; + isActive?: boolean; +} + +export interface AddressLabelSearchParams { + category?: string; + chain?: string; + query?: string; + includeInactive?: boolean; + limit?: number; + offset?: number; +} + +/** + * Transaction address labeling service (#1152). + * + * Attaches human-readable metadata (exchange, bridge contract, suspicious, + * etc.) to on-chain addresses so transaction lists, alerts, and investigation + * tooling can surface "who" an address belongs to instead of a raw hash. + */ +export class AddressLabelService { + private model = new AddressLabelModel(); + + private normalizeAddress(address: string): string { + return address.trim(); + } + + private assertValidChain(chain: string): asserts chain is AddressLabelChain { + if (!VALID_CHAINS.includes(chain as AddressLabelChain)) { + throw new Error(`Unsupported chain "${chain}". Expected one of: ${VALID_CHAINS.join(", ")}`); + } + } + + private assertValidCategory(category: string): asserts category is AddressLabelCategory { + if (!VALID_CATEGORIES.includes(category as AddressLabelCategory)) { + throw new Error(`Unsupported category "${category}". Expected one of: ${VALID_CATEGORIES.join(", ")}`); + } + } + + private assertValidConfidence(confidence: number): void { + if (!Number.isFinite(confidence) || confidence < 0 || confidence > 100) { + throw new Error("Confidence must be a number between 0 and 100"); + } + } + + async createLabel(params: CreateAddressLabelParams): Promise { + const address = this.normalizeAddress(params.address); + if (!address) { + throw new Error("Address is required"); + } + if (!params.label || !params.label.trim()) { + throw new Error("Label is required"); + } + + const chain = (params.chain ?? "stellar").toLowerCase(); + this.assertValidChain(chain); + + const category = (params.category ?? "other").toLowerCase(); + this.assertValidCategory(category); + + const confidence = params.confidence ?? 100; + this.assertValidConfidence(confidence); + + const existing = await this.model.findByAddressChain(address, chain); + if (existing) { + throw new Error(`Address "${address}" on chain "${chain}" is already labeled`); + } + + const label = await this.model.create({ + id: crypto.randomUUID(), + address, + chain, + label: params.label.trim(), + category, + notes: params.notes ?? null, + confidence, + source: params.source ?? "manual", + created_by: params.performedBy, + }); + + await auditService.log({ + action: "address_label.created", + actorId: params.performedBy, + actorType: "user", + resourceType: "address_label", + resourceId: label.id, + after: label as any, + metadata: { address: label.address, chain: label.chain, category: label.category }, + }); + + logger.info({ addressLabelId: label.id, address, chain }, "Address label created"); + return label; + } + + async getLabel(id: string): Promise { + const label = await this.model.findById(id); + return label ?? null; + } + + async lookupAddress(address: string, chain = "stellar"): Promise { + const label = await this.model.findByAddressChain(this.normalizeAddress(address), chain.toLowerCase()); + return label ?? null; + } + + /** + * Bulk lookup used to enrich a page of transactions in a single query + * instead of one lookup per row. + */ + async lookupAddresses(addresses: string[], chain?: string): Promise> { + const unique = Array.from(new Set(addresses.map((a) => this.normalizeAddress(a)).filter(Boolean))); + const labels = await this.model.findByAddresses(unique, chain); + const byAddress = new Map(); + for (const label of labels) { + byAddress.set(label.address, label); + } + return byAddress; + } + + async searchLabels(params: AddressLabelSearchParams): Promise { + if (params.category) this.assertValidCategory(params.category.toLowerCase()); + if (params.chain) this.assertValidChain(params.chain.toLowerCase()); + + return this.model.search({ + category: params.category?.toLowerCase(), + chain: params.chain?.toLowerCase(), + query: params.query?.trim(), + includeInactive: params.includeInactive, + limit: params.limit, + offset: params.offset, + }); + } + + async updateLabel( + id: string, + params: UpdateAddressLabelParams, + performedBy: string + ): Promise { + const existing = await this.model.findById(id); + if (!existing) { + throw new Error(`Address label "${id}" not found`); + } + + const updateData: Partial> = {}; + + if (params.label !== undefined) { + if (!params.label.trim()) { + throw new Error("Label cannot be empty"); + } + updateData.label = params.label.trim(); + } + if (params.category !== undefined) { + const category = params.category.toLowerCase(); + this.assertValidCategory(category); + updateData.category = category; + } + if (params.notes !== undefined) { + updateData.notes = params.notes; + } + if (params.confidence !== undefined) { + this.assertValidConfidence(params.confidence); + updateData.confidence = params.confidence; + } + if (params.isActive !== undefined) { + updateData.is_active = params.isActive; + } + + if (Object.keys(updateData).length === 0) { + return existing; + } + + const updated = await this.model.update(id, updateData); + if (!updated) { + throw new Error("Failed to update address label"); + } + + await auditService.log({ + action: "address_label.updated", + actorId: performedBy, + actorType: "user", + resourceType: "address_label", + resourceId: id, + before: existing as any, + after: updated as any, + metadata: { changes: updateData }, + }); + + logger.info({ addressLabelId: id, performedBy }, "Address label updated"); + return updated; + } + + async deleteLabel(id: string, performedBy: string): Promise { + const existing = await this.model.findById(id); + if (!existing) { + throw new Error(`Address label "${id}" not found`); + } + + const deleted = await this.model.delete(id); + + if (deleted) { + await auditService.log({ + action: "address_label.deleted", + actorId: performedBy, + actorType: "user", + resourceType: "address_label", + resourceId: id, + before: existing as any, + metadata: { address: existing.address, chain: existing.chain }, + }); + logger.info({ addressLabelId: id, performedBy }, "Address label deleted"); + } + + return deleted; + } +} + +export const addressLabelService = new AddressLabelService(); diff --git a/backend/src/services/bridgeComparisonReport.service.ts b/backend/src/services/bridgeComparisonReport.service.ts new file mode 100644 index 00000000..73f53e93 --- /dev/null +++ b/backend/src/services/bridgeComparisonReport.service.ts @@ -0,0 +1,184 @@ +import { BridgeService, type BridgeStats } from "./bridge.service.js"; +import { logger } from "../utils/logger.js"; + +export interface BridgeComparisonRow extends BridgeStats { + /** Rank (1 = best) among the compared bridges by total value locked. */ + tvlRank: number; + /** Rank (1 = best) by 30-day uptime. */ + uptimeRank: number; + /** Rank (1 = best, i.e. fastest) by average transfer time. */ + transferSpeedRank: number; + /** Share of the combined TVL across all compared bridges, 0-1. */ + tvlShare: number; +} + +export interface BridgeComparisonReport { + generatedAt: string; + dateRange?: { startDate?: string; endDate?: string }; + bridges: BridgeComparisonRow[]; + summary: { + bridgeCount: number; + combinedTvl: number; + combinedVolume30d: number; + combinedTransactions: number; + bestTvl: string | null; + bestUptime: string | null; + fastestTransfer: string | null; + }; +} + +function rankDescending(values: number[]): number[] { + const sortedDesc = [...values].sort((a, b) => b - a); + return values.map((value) => sortedDesc.indexOf(value) + 1); +} + +function rankAscending(values: number[]): number[] { + const sortedAsc = [...values].sort((a, b) => a - b); + return values.map((value) => sortedAsc.indexOf(value) + 1); +} + +/** + * Bridge comparison report service (#1149). + * + * Builds a side-by-side comparison of bridge performance (TVL, volume, + * uptime, transfer speed) so operators and users can evaluate which bridge + * best fits a given transfer instead of inspecting each bridge in isolation. + */ +export class BridgeComparisonReportService { + private readonly bridgeService = new BridgeService(); + + /** + * Pure aggregation step, kept separate from data fetching so the ranking + * math can be unit tested without a database. + */ + buildReport( + stats: BridgeStats[], + dateRange?: { startDate?: string; endDate?: string } + ): BridgeComparisonReport { + if (stats.length === 0) { + return { + generatedAt: new Date().toISOString(), + dateRange, + bridges: [], + summary: { + bridgeCount: 0, + combinedTvl: 0, + combinedVolume30d: 0, + combinedTransactions: 0, + bestTvl: null, + bestUptime: null, + fastestTransfer: null, + }, + }; + } + + const tvlValues = stats.map((s) => s.totalValueLocked); + const uptimeValues = stats.map((s) => s.uptime30d); + const transferTimeValues = stats.map((s) => s.averageTransferTime); + + const tvlRanks = rankDescending(tvlValues); + const uptimeRanks = rankDescending(uptimeValues); + const transferSpeedRanks = rankAscending(transferTimeValues); + + const combinedTvl = tvlValues.reduce((sum, v) => sum + v, 0); + const combinedVolume30d = stats.reduce((sum, s) => sum + s.volume30d, 0); + const combinedTransactions = stats.reduce((sum, s) => sum + s.totalTransactions, 0); + + const bridges: BridgeComparisonRow[] = stats.map((s, i) => ({ + ...s, + tvlRank: tvlRanks[i], + uptimeRank: uptimeRanks[i], + transferSpeedRank: transferSpeedRanks[i], + tvlShare: combinedTvl > 0 ? s.totalValueLocked / combinedTvl : 0, + })); + + const bestTvl = bridges.find((b) => b.tvlRank === 1)?.name ?? null; + const bestUptime = bridges.find((b) => b.uptimeRank === 1)?.name ?? null; + const fastestTransfer = bridges.find((b) => b.transferSpeedRank === 1)?.name ?? null; + + return { + generatedAt: new Date().toISOString(), + dateRange, + bridges: bridges.sort((a, b) => a.tvlRank - b.tvlRank), + summary: { + bridgeCount: bridges.length, + combinedTvl, + combinedVolume30d, + combinedTransactions, + bestTvl, + bestUptime, + fastestTransfer, + }, + }; + } + + /** + * Fetches stats for the requested bridges (or all known bridges when + * `bridgeNames` is omitted) and builds the comparison report. + */ + async generateReport( + bridgeNames?: string[], + dateRange?: { startDate?: string; endDate?: string } + ): Promise { + let names = bridgeNames; + if (!names || names.length === 0) { + const { bridges } = await this.bridgeService.getAllBridgeStatuses(); + names = bridges.map((b) => b.name); + } + + const statsResults = await Promise.all( + names.map((name) => this.bridgeService.getBridgeStats(name, dateRange)) + ); + const stats = statsResults.filter((s): s is BridgeStats => s !== null); + + logger.info( + { requested: names.length, resolved: stats.length }, + "Generated bridge comparison report" + ); + + return this.buildReport(stats, dateRange); + } + + /** + * Renders a comparison report as CSV for download/export workflows. + */ + toCsv(report: BridgeComparisonReport): string { + const header = [ + "name", + "status", + "totalValueLocked", + "tvlRank", + "tvlShare", + "volume24h", + "volume7d", + "volume30d", + "totalTransactions", + "averageTransferTime", + "transferSpeedRank", + "uptime30d", + "uptimeRank", + ]; + + const rows = report.bridges.map((b) => + [ + b.name, + b.status, + b.totalValueLocked, + b.tvlRank, + b.tvlShare.toFixed(4), + b.volume24h, + b.volume7d, + b.volume30d, + b.totalTransactions, + b.averageTransferTime, + b.transferSpeedRank, + b.uptime30d, + b.uptimeRank, + ].join(",") + ); + + return [header.join(","), ...rows].join("\n"); + } +} + +export const bridgeComparisonReportService = new BridgeComparisonReportService(); diff --git a/backend/src/services/chartSamplingControls.service.ts b/backend/src/services/chartSamplingControls.service.ts new file mode 100644 index 00000000..a924794e --- /dev/null +++ b/backend/src/services/chartSamplingControls.service.ts @@ -0,0 +1,297 @@ +import crypto from "crypto"; +import { getDatabase } from "../database/connection.js"; +import { logger } from "../utils/logger.js"; + +export type SamplingStrategy = "lttb" | "fixed_interval" | "min_max" | "nth_point"; + +export interface ChartDataPoint { + timestamp: number; + value: number; +} + +export interface ChartSamplingProfile { + id: string; + name: string; + description: string | null; + strategy: SamplingStrategy; + max_points: number; + min_interval_seconds: number | null; + enabled: boolean; + created_by: string; + created_at: Date; + updated_at: Date; +} + +const MAX_ALLOWED_POINTS = 100_000; + +function assertValidPoints(points: ChartDataPoint[]): void { + if (!Array.isArray(points)) { + throw new Error("points must be an array"); + } +} + +function assertValidMaxPoints(maxPoints: number): void { + if (!Number.isInteger(maxPoints) || maxPoints <= 0 || maxPoints > MAX_ALLOWED_POINTS) { + throw new Error(`maxPoints must be an integer between 1 and ${MAX_ALLOWED_POINTS}`); + } +} + +/** + * Chart data sampling algorithms (#1151). + * + * Pure, dependency-free downsampling functions used to keep chart payloads + * within a configurable point budget while preserving the visual shape of + * the underlying series (spikes, trend reversals) as much as possible. + */ +export class ChartDataSampler { + /** Every Nth point, keeping the first and last point. Cheapest strategy. */ + static nthPoint(points: ChartDataPoint[], maxPoints: number): ChartDataPoint[] { + assertValidPoints(points); + assertValidMaxPoints(maxPoints); + if (points.length <= maxPoints) return [...points]; + + const step = (points.length - 1) / (maxPoints - 1); + const result: ChartDataPoint[] = []; + for (let i = 0; i < maxPoints; i++) { + result.push(points[Math.round(i * step)]); + } + return result; + } + + /** Fixed-interval bucketing: one point (the average) per time bucket. */ + static fixedInterval(points: ChartDataPoint[], maxPoints: number): ChartDataPoint[] { + assertValidPoints(points); + assertValidMaxPoints(maxPoints); + if (points.length <= maxPoints) return [...points]; + + const first = points[0].timestamp; + const last = points[points.length - 1].timestamp; + const span = Math.max(last - first, 1); + const bucketSize = span / maxPoints; + + const buckets = new Map(); + for (const point of points) { + const bucketIndex = Math.min(maxPoints - 1, Math.floor((point.timestamp - first) / bucketSize)); + const bucket = buckets.get(bucketIndex) ?? { sum: 0, count: 0, timestampSum: 0 }; + bucket.sum += point.value; + bucket.timestampSum += point.timestamp; + bucket.count += 1; + buckets.set(bucketIndex, bucket); + } + + return Array.from(buckets.entries()) + .sort(([a], [b]) => a - b) + .map(([, bucket]) => ({ + timestamp: Math.round(bucket.timestampSum / bucket.count), + value: bucket.sum / bucket.count, + })); + } + + /** + * Min/max decimation: for each bucket, keeps both the minimum and maximum + * value so spikes and dips are never smoothed away, at the cost of using + * up to 2 output points per bucket. + */ + static minMax(points: ChartDataPoint[], maxPoints: number): ChartDataPoint[] { + assertValidPoints(points); + assertValidMaxPoints(maxPoints); + if (points.length <= maxPoints) return [...points]; + + const bucketCount = Math.max(1, Math.floor(maxPoints / 2)); + const bucketSize = points.length / bucketCount; + + const result: ChartDataPoint[] = []; + for (let i = 0; i < bucketCount; i++) { + const start = Math.floor(i * bucketSize); + const end = i === bucketCount - 1 ? points.length : Math.floor((i + 1) * bucketSize); + const slice = points.slice(start, end); + if (slice.length === 0) continue; + + let minPoint = slice[0]; + let maxPoint = slice[0]; + for (const point of slice) { + if (point.value < minPoint.value) minPoint = point; + if (point.value > maxPoint.value) maxPoint = point; + } + + if (minPoint.timestamp <= maxPoint.timestamp) { + result.push(minPoint, maxPoint); + } else { + result.push(maxPoint, minPoint); + } + } + + return result; + } + + /** + * Largest-Triangle-Three-Buckets: preserves visual shape (trend reversals) + * better than naive decimation by picking, per bucket, the point that + * forms the largest triangle with the previously selected point and the + * average of the next bucket. + */ + static lttb(points: ChartDataPoint[], maxPoints: number): ChartDataPoint[] { + assertValidPoints(points); + assertValidMaxPoints(maxPoints); + if (maxPoints >= points.length || maxPoints < 3) { + return points.length <= maxPoints ? [...points] : ChartDataSampler.nthPoint(points, maxPoints); + } + + const sampled: ChartDataPoint[] = [points[0]]; + const bucketSize = (points.length - 2) / (maxPoints - 2); + let a = 0; + + for (let i = 0; i < maxPoints - 2; i++) { + const rangeStart = Math.floor((i + 1) * bucketSize) + 1; + const rangeEnd = Math.floor((i + 2) * bucketSize) + 1; + const nextRangeEnd = Math.min(rangeEnd, points.length); + + let avgX = 0; + let avgY = 0; + const avgRangeStart = rangeEnd; + const avgRangeEnd = Math.min(Math.floor((i + 3) * bucketSize) + 1, points.length); + const avgSlice = points.slice(avgRangeStart, avgRangeEnd); + const avgCount = avgSlice.length || 1; + for (const p of avgSlice) { + avgX += p.timestamp; + avgY += p.value; + } + avgX /= avgCount; + avgY /= avgCount; + + const pointA = points[a]; + let maxArea = -1; + let maxAreaIndex = rangeStart; + + for (let j = rangeStart; j < nextRangeEnd; j++) { + const point = points[j]; + const area = Math.abs( + (pointA.timestamp - avgX) * (point.value - pointA.value) - + (pointA.timestamp - point.timestamp) * (avgY - pointA.value) + ); + if (area > maxArea) { + maxArea = area; + maxAreaIndex = j; + } + } + + sampled.push(points[maxAreaIndex]); + a = maxAreaIndex; + } + + sampled.push(points[points.length - 1]); + return sampled; + } + + static sample( + points: ChartDataPoint[], + strategy: SamplingStrategy, + maxPoints: number + ): ChartDataPoint[] { + switch (strategy) { + case "lttb": + return ChartDataSampler.lttb(points, maxPoints); + case "fixed_interval": + return ChartDataSampler.fixedInterval(points, maxPoints); + case "min_max": + return ChartDataSampler.minMax(points, maxPoints); + case "nth_point": + return ChartDataSampler.nthPoint(points, maxPoints); + default: + throw new Error(`Unsupported sampling strategy "${strategy}"`); + } + } +} + +/** + * Chart data sampling controls service (#1151). + * + * Manages reusable, named sampling profiles (persisted) on top of the pure + * `ChartDataSampler` algorithms so dashboards can request a downsampled + * series by profile name instead of repeating strategy/maxPoints on every call. + */ +export class ChartSamplingControlsService { + private table = "chart_sampling_profiles"; + + sampleSeries( + points: ChartDataPoint[], + options: { strategy?: SamplingStrategy; maxPoints?: number } + ): ChartDataPoint[] { + const strategy = options.strategy ?? "lttb"; + const maxPoints = options.maxPoints ?? 500; + return ChartDataSampler.sample(points, strategy, maxPoints); + } + + async createProfile(params: { + name: string; + description?: string | null; + strategy?: SamplingStrategy; + maxPoints?: number; + minIntervalSeconds?: number | null; + createdBy: string; + }): Promise { + if (!params.name || !params.name.trim()) { + throw new Error("Profile name is required"); + } + const maxPoints = params.maxPoints ?? 500; + assertValidMaxPoints(maxPoints); + + const db = getDatabase(); + const existing = await db(this.table).where({ name: params.name.trim() }).first(); + if (existing) { + throw new Error(`Sampling profile "${params.name}" already exists`); + } + + const [row] = await db(this.table) + .insert({ + id: crypto.randomUUID(), + name: params.name.trim(), + description: params.description ?? null, + strategy: params.strategy ?? "lttb", + max_points: maxPoints, + min_interval_seconds: params.minIntervalSeconds ?? null, + enabled: true, + created_by: params.createdBy, + created_at: new Date(), + updated_at: new Date(), + }) + .returning("*"); + + logger.info({ profileId: row.id, name: row.name }, "Chart sampling profile created"); + return row; + } + + async listProfiles(): Promise { + const db = getDatabase(); + return db(this.table).select("*").orderBy("name", "asc"); + } + + async getProfileByName(name: string): Promise { + const db = getDatabase(); + const row = await db(this.table).where({ name }).first(); + return row ?? null; + } + + async deleteProfile(id: string): Promise { + const db = getDatabase(); + const count = await db(this.table).where({ id }).del(); + return count > 0; + } + + /** Downsamples a series using a saved profile's configuration. */ + async sampleWithProfile(name: string, points: ChartDataPoint[]): Promise { + const profile = await this.getProfileByName(name); + if (!profile) { + throw new Error(`Sampling profile "${name}" not found`); + } + if (!profile.enabled) { + throw new Error(`Sampling profile "${name}" is disabled`); + } + return this.sampleSeries(points, { + strategy: profile.strategy, + maxPoints: profile.max_points, + }); + } +} + +export const chartSamplingControlsService = new ChartSamplingControlsService(); diff --git a/backend/src/services/liquidityHeatmapExport.service.ts b/backend/src/services/liquidityHeatmapExport.service.ts new file mode 100644 index 00000000..22497cb9 --- /dev/null +++ b/backend/src/services/liquidityHeatmapExport.service.ts @@ -0,0 +1,160 @@ +import { getDatabase } from "../database/connection.js"; +import { logger } from "../utils/logger.js"; + +export type HeatmapInterval = "hour" | "day"; + +export interface LiquiditySnapshotRow { + time: Date | string; + symbol: string; + dex: string; + tvl_usd: number | string; +} + +export interface HeatmapCell { + bucket: string; + symbol: string; + dex: string; + tvlUsd: number; +} + +export interface HeatmapAxis { + buckets: string[]; + symbols: string[]; + dexes: string[]; +} + +export interface LiquidityHeatmap { + interval: HeatmapInterval; + startDate: string | null; + endDate: string | null; + axis: HeatmapAxis; + cells: HeatmapCell[]; + /** matrix[symbol][bucket] = summed TVL across all dexes for that symbol/bucket */ + matrix: Record>; +} + +function truncateToBucket(date: Date, interval: HeatmapInterval): string { + const d = new Date(date); + d.setUTCMinutes(0, 0, 0); + if (interval === "day") { + d.setUTCHours(0); + return d.toISOString().slice(0, 10); + } + return d.toISOString().slice(0, 13) + ":00:00.000Z"; +} + +/** + * Historical liquidity heatmap export service (#1150). + * + * Aggregates raw liquidity snapshots into a symbol x time bucket matrix so + * the frontend heatmap (and downstream reporting/export) doesn't need to + * reprocess raw time-series rows on every render. + */ +export class LiquidityHeatmapExportService { + /** + * Pure aggregation over already-fetched rows, kept separate from the DB + * query so the bucketing/matrix logic is unit testable without a database. + */ + buildHeatmap( + rows: LiquiditySnapshotRow[], + options: { interval?: HeatmapInterval; startDate?: string; endDate?: string } = {} + ): LiquidityHeatmap { + const interval = options.interval ?? "day"; + + const cellMap = new Map(); + const buckets = new Set(); + const symbols = new Set(); + const dexes = new Set(); + + for (const row of rows) { + const bucket = truncateToBucket(new Date(row.time), interval); + const tvl = Number(row.tvl_usd) || 0; + const key = `${row.symbol}|${row.dex}|${bucket}`; + + const existing = cellMap.get(key); + if (existing) { + existing.tvlUsd += tvl; + } else { + cellMap.set(key, { bucket, symbol: row.symbol, dex: row.dex, tvlUsd: tvl }); + } + + buckets.add(bucket); + symbols.add(row.symbol); + dexes.add(row.dex); + } + + const cells = Array.from(cellMap.values()).sort((a, b) => a.bucket.localeCompare(b.bucket)); + + const matrix: Record> = {}; + for (const cell of cells) { + matrix[cell.symbol] ??= {}; + matrix[cell.symbol][cell.bucket] = (matrix[cell.symbol][cell.bucket] ?? 0) + cell.tvlUsd; + } + + return { + interval, + startDate: options.startDate ?? null, + endDate: options.endDate ?? null, + axis: { + buckets: Array.from(buckets).sort(), + symbols: Array.from(symbols).sort(), + dexes: Array.from(dexes).sort(), + }, + cells, + matrix, + }; + } + + async fetchSnapshots(params: { + startDate: string; + endDate: string; + symbols?: string[]; + }): Promise { + const db = getDatabase(); + const query = db("liquidity_snapshots") + .select("time", "symbol", "dex", "tvl_usd") + .where("time", ">=", params.startDate) + .andWhere("time", "<=", params.endDate); + + if (params.symbols && params.symbols.length > 0) { + query.andWhere("symbol", "in", params.symbols); + } + + return query.orderBy("time", "asc"); + } + + async exportHeatmap(params: { + startDate: string; + endDate: string; + symbols?: string[]; + interval?: HeatmapInterval; + }): Promise { + const rows = await this.fetchSnapshots(params); + logger.info( + { startDate: params.startDate, endDate: params.endDate, rowCount: rows.length }, + "Exporting historical liquidity heatmap" + ); + return this.buildHeatmap(rows, { + interval: params.interval, + startDate: params.startDate, + endDate: params.endDate, + }); + } + + /** + * Renders the heatmap as a symbol x bucket CSV matrix for spreadsheet-style + * export/download workflows. + */ + toCsv(heatmap: LiquidityHeatmap): string { + const header = ["symbol", ...heatmap.axis.buckets]; + const rows = heatmap.axis.symbols.map((symbol) => { + const values = heatmap.axis.buckets.map((bucket) => + (heatmap.matrix[symbol]?.[bucket] ?? 0).toString() + ); + return [symbol, ...values].join(","); + }); + return [header.join(","), ...rows].join("\n"); + } +} + +export const liquidityHeatmapExportService = new LiquidityHeatmapExportService(); diff --git a/backend/tests/services/addressLabel.service.test.ts b/backend/tests/services/addressLabel.service.test.ts new file mode 100644 index 00000000..5a1d8e4b --- /dev/null +++ b/backend/tests/services/addressLabel.service.test.ts @@ -0,0 +1,156 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { getDatabase } from "../../src/database/connection.js"; +import { AddressLabelService } from "../../src/services/addressLabel.service.js"; + +vi.mock("../../src/database/connection.js", () => { + const mockDbQuery: any = { + select: vi.fn().mockReturnThis(), + orderBy: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + andWhere: vi.fn().mockReturnThis(), + whereIn: vi.fn().mockReturnThis(), + whereILike: vi.fn().mockReturnThis(), + orWhereILike: vi.fn().mockReturnThis(), + limit: vi.fn().mockReturnThis(), + offset: vi.fn().mockReturnThis(), + first: vi.fn().mockReturnThis(), + insert: vi.fn().mockReturnThis(), + update: vi.fn().mockReturnThis(), + del: vi.fn().mockResolvedValue(0), + returning: vi.fn().mockReturnThis(), + }; + + const mockDb: any = vi.fn().mockImplementation(() => mockDbQuery); + + return { getDatabase: () => mockDb }; +}); + +vi.mock("../../src/services/audit.service.js", () => ({ + auditService: { log: vi.fn().mockResolvedValue({}) }, +})); + +vi.mock("../../src/utils/logger.js", () => ({ + logger: { info: vi.fn(), error: vi.fn(), debug: vi.fn() }, +})); + +describe("AddressLabelService", () => { + let service: AddressLabelService; + let mockDb: any; + let mockDbQuery: any; + + beforeEach(() => { + service = new AddressLabelService(); + vi.clearAllMocks(); + + mockDb = getDatabase(); + mockDbQuery = mockDb(); + + mockDbQuery.first.mockResolvedValue(undefined); + mockDbQuery.returning.mockResolvedValue([]); + mockDbQuery.del.mockResolvedValue(0); + }); + + describe("createLabel", () => { + it("creates a label for a valid address", async () => { + const created = { + id: "label-1", + address: "GABCXYZ", + chain: "stellar", + label: "Known exchange hot wallet", + category: "exchange", + }; + mockDbQuery.first.mockResolvedValue(undefined); + mockDbQuery.returning.mockResolvedValue([created]); + + const result = await service.createLabel({ + address: "GABCXYZ", + label: "Known exchange hot wallet", + category: "exchange", + performedBy: "admin-1", + }); + + expect(result).toEqual(created); + }); + + it("rejects an empty address", async () => { + await expect( + service.createLabel({ address: " ", label: "x", performedBy: "admin-1" }) + ).rejects.toThrow("Address is required"); + }); + + it("rejects an unsupported chain", async () => { + await expect( + service.createLabel({ address: "0xabc", chain: "made-up-chain", label: "x", performedBy: "admin-1" }) + ).rejects.toThrow("Unsupported chain"); + }); + + it("rejects an unsupported category", async () => { + await expect( + service.createLabel({ address: "0xabc", category: "made-up", label: "x", performedBy: "admin-1" }) + ).rejects.toThrow("Unsupported category"); + }); + + it("rejects a confidence value out of range", async () => { + await expect( + service.createLabel({ address: "0xabc", label: "x", confidence: 150, performedBy: "admin-1" }) + ).rejects.toThrow("Confidence must be"); + }); + + it("rejects a duplicate address+chain", async () => { + mockDbQuery.first.mockResolvedValue({ id: "existing", address: "GABCXYZ", chain: "stellar" }); + + await expect( + service.createLabel({ address: "GABCXYZ", label: "dup", performedBy: "admin-1" }) + ).rejects.toThrow("already labeled"); + }); + }); + + describe("updateLabel", () => { + it("throws when the label does not exist", async () => { + mockDbQuery.first.mockResolvedValue(undefined); + await expect( + service.updateLabel("missing-id", { label: "new" }, "admin-1") + ).rejects.toThrow("not found"); + }); + + it("rejects an invalid confidence on update", async () => { + mockDbQuery.first.mockResolvedValue({ id: "1", label: "old" }); + await expect( + service.updateLabel("1", { confidence: -5 }, "admin-1") + ).rejects.toThrow("Confidence must be"); + }); + }); + + describe("deleteLabel", () => { + it("throws when the label does not exist", async () => { + mockDbQuery.first.mockResolvedValue(undefined); + await expect(service.deleteLabel("missing-id", "admin-1")).rejects.toThrow("not found"); + }); + + it("deletes an existing label", async () => { + mockDbQuery.first.mockResolvedValue({ id: "1", address: "GABCXYZ", chain: "stellar" }); + mockDbQuery.del.mockResolvedValue(1); + + await expect(service.deleteLabel("1", "admin-1")).resolves.toBe(true); + }); + }); + + describe("lookupAddresses", () => { + it("de-duplicates addresses and maps results by address", async () => { + mockDbQuery.whereIn.mockReturnThis(); + mockDbQuery.andWhere.mockReturnThis(); + // findByAddresses resolves the query builder itself as a thenable-less mock, + // so make it directly return the row array via mockResolvedValue on the + // final chain call ("andWhere" here since no explicit `.then`). + const rows = [{ id: "1", address: "GABCXYZ", chain: "stellar" }]; + mockDb.mockImplementation(() => ({ + ...mockDbQuery, + whereIn: vi.fn().mockReturnThis(), + andWhere: vi.fn().mockResolvedValue(rows), + })); + + const result = await service.lookupAddresses(["GABCXYZ", "GABCXYZ", ""]); + expect(result.get("GABCXYZ")).toEqual(rows[0]); + }); + }); +}); diff --git a/backend/tests/services/bridgeComparisonReport.service.test.ts b/backend/tests/services/bridgeComparisonReport.service.test.ts new file mode 100644 index 00000000..d164c4f5 --- /dev/null +++ b/backend/tests/services/bridgeComparisonReport.service.test.ts @@ -0,0 +1,104 @@ +import { describe, it, expect } from "vitest"; +import { BridgeComparisonReportService } from "../../src/services/bridgeComparisonReport.service.js"; +import type { BridgeStats } from "../../src/services/bridge.service.js"; + +function makeStats(overrides: Partial & { name: string }): BridgeStats { + return { + name: overrides.name, + totalValueLocked: 0, + supplyOnStellar: 0, + supplyOnSource: 0, + status: "healthy", + volume24h: 0, + volume7d: 0, + volume30d: 0, + totalTransactions: 0, + averageTransferTime: 0, + uptime30d: 100, + ...overrides, + }; +} + +describe("BridgeComparisonReportService", () => { + const service = new BridgeComparisonReportService(); + + describe("buildReport", () => { + it("returns an empty report when no bridges are provided", () => { + const report = service.buildReport([]); + + expect(report.bridges).toEqual([]); + expect(report.summary).toEqual({ + bridgeCount: 0, + combinedTvl: 0, + combinedVolume30d: 0, + combinedTransactions: 0, + bestTvl: null, + bestUptime: null, + fastestTransfer: null, + }); + }); + + it("ranks bridges by TVL, uptime, and transfer speed independently", () => { + const stats = [ + makeStats({ name: "Wormhole", totalValueLocked: 100, uptime30d: 90, averageTransferTime: 120 }), + makeStats({ name: "Allbridge", totalValueLocked: 300, uptime30d: 99, averageTransferTime: 60 }), + makeStats({ name: "Circle", totalValueLocked: 200, uptime30d: 95, averageTransferTime: 30 }), + ]; + + const report = service.buildReport(stats); + const byName = Object.fromEntries(report.bridges.map((b) => [b.name, b])); + + expect(byName.Allbridge.tvlRank).toBe(1); + expect(byName.Circle.tvlRank).toBe(2); + expect(byName.Wormhole.tvlRank).toBe(3); + + expect(byName.Allbridge.uptimeRank).toBe(1); + expect(byName.Circle.transferSpeedRank).toBe(1); + + expect(report.summary.bestTvl).toBe("Allbridge"); + expect(report.summary.bestUptime).toBe("Allbridge"); + expect(report.summary.fastestTransfer).toBe("Circle"); + }); + + it("computes tvlShare proportional to combined TVL", () => { + const stats = [ + makeStats({ name: "A", totalValueLocked: 100 }), + makeStats({ name: "B", totalValueLocked: 300 }), + ]; + + const report = service.buildReport(stats); + const byName = Object.fromEntries(report.bridges.map((b) => [b.name, b])); + + expect(byName.A.tvlShare).toBeCloseTo(0.25); + expect(byName.B.tvlShare).toBeCloseTo(0.75); + expect(report.summary.combinedTvl).toBe(400); + }); + + it("sorts the bridges array by TVL rank ascending", () => { + const stats = [ + makeStats({ name: "Low", totalValueLocked: 10 }), + makeStats({ name: "High", totalValueLocked: 1000 }), + makeStats({ name: "Mid", totalValueLocked: 100 }), + ]; + + const report = service.buildReport(stats); + expect(report.bridges.map((b) => b.name)).toEqual(["High", "Mid", "Low"]); + }); + }); + + describe("toCsv", () => { + it("renders a header row followed by one row per bridge", () => { + const report = service.buildReport([ + makeStats({ name: "Wormhole", totalValueLocked: 100 }), + makeStats({ name: "Allbridge", totalValueLocked: 200 }), + ]); + + const csv = service.toCsv(report); + const lines = csv.split("\n"); + + expect(lines[0]).toContain("name"); + expect(lines).toHaveLength(3); + expect(lines[1]).toContain("Allbridge"); + }); + }); +}); diff --git a/backend/tests/services/chartSamplingControls.service.test.ts b/backend/tests/services/chartSamplingControls.service.test.ts new file mode 100644 index 00000000..eddf89c0 --- /dev/null +++ b/backend/tests/services/chartSamplingControls.service.test.ts @@ -0,0 +1,111 @@ +import { describe, it, expect } from "vitest"; +import { ChartDataSampler, type ChartDataPoint } from "../../src/services/chartSamplingControls.service.js"; + +function generateSeries(count: number): ChartDataPoint[] { + return Array.from({ length: count }, (_, i) => ({ + timestamp: i * 1000, + value: Math.sin(i / 10) * 100 + i, + })); +} + +describe("ChartDataSampler", () => { + describe("input validation", () => { + it("rejects a maxPoints of 0", () => { + expect(() => ChartDataSampler.nthPoint(generateSeries(10), 0)).toThrow("maxPoints"); + }); + + it("rejects a non-integer maxPoints", () => { + expect(() => ChartDataSampler.fixedInterval(generateSeries(10), 2.5)).toThrow("maxPoints"); + }); + + it("rejects maxPoints above the allowed ceiling", () => { + expect(() => ChartDataSampler.minMax(generateSeries(10), 1_000_000)).toThrow("maxPoints"); + }); + }); + + describe("nthPoint", () => { + it("returns the original series untouched when already within budget", () => { + const points = generateSeries(50); + expect(ChartDataSampler.nthPoint(points, 100)).toEqual(points); + }); + + it("downsamples to exactly maxPoints and preserves first/last points", () => { + const points = generateSeries(1000); + const sampled = ChartDataSampler.nthPoint(points, 100); + + expect(sampled).toHaveLength(100); + expect(sampled[0]).toEqual(points[0]); + expect(sampled[sampled.length - 1]).toEqual(points[points.length - 1]); + }); + }); + + describe("fixedInterval", () => { + it("produces at most maxPoints buckets", () => { + const points = generateSeries(1000); + const sampled = ChartDataSampler.fixedInterval(points, 50); + expect(sampled.length).toBeLessThanOrEqual(50); + }); + + it("averages values within each bucket", () => { + const points: ChartDataPoint[] = [ + { timestamp: 0, value: 10 }, + { timestamp: 1, value: 20 }, + ]; + const sampled = ChartDataSampler.fixedInterval(points, 1); + expect(sampled).toHaveLength(1); + expect(sampled[0].value).toBe(15); + }); + }); + + describe("minMax", () => { + it("captures a spike that fixed-interval bucketing could smooth away", () => { + const points: ChartDataPoint[] = generateSeries(100).map((p, i) => + i === 50 ? { ...p, value: 100000 } : { ...p, value: 0 } + ); + + const sampled = ChartDataSampler.minMax(points, 20); + expect(sampled.some((p) => p.value === 100000)).toBe(true); + }); + + it("returns at most maxPoints entries", () => { + const points = generateSeries(500); + const sampled = ChartDataSampler.minMax(points, 40); + expect(sampled.length).toBeLessThanOrEqual(40); + }); + }); + + describe("lttb", () => { + it("always keeps the first and last point", () => { + const points = generateSeries(1000); + const sampled = ChartDataSampler.lttb(points, 50); + + expect(sampled[0]).toEqual(points[0]); + expect(sampled[sampled.length - 1]).toEqual(points[points.length - 1]); + }); + + it("returns the original series when it is already within budget", () => { + const points = generateSeries(10); + expect(ChartDataSampler.lttb(points, 100)).toEqual(points); + }); + + it("produces exactly maxPoints points for a larger series", () => { + const points = generateSeries(2000); + const sampled = ChartDataSampler.lttb(points, 200); + expect(sampled).toHaveLength(200); + }); + }); + + describe("sample dispatcher", () => { + it("throws on an unsupported strategy", () => { + expect(() => + ChartDataSampler.sample(generateSeries(10), "unknown" as any, 5) + ).toThrow("Unsupported sampling strategy"); + }); + + it("dispatches to the correct algorithm for each strategy", () => { + const points = generateSeries(100); + expect(ChartDataSampler.sample(points, "lttb", 10)).toHaveLength(10); + expect(ChartDataSampler.sample(points, "nth_point", 10)).toHaveLength(10); + }); + }); +}); diff --git a/backend/tests/services/liquidityHeatmapExport.service.test.ts b/backend/tests/services/liquidityHeatmapExport.service.test.ts new file mode 100644 index 00000000..9e071fcd --- /dev/null +++ b/backend/tests/services/liquidityHeatmapExport.service.test.ts @@ -0,0 +1,60 @@ +import { describe, it, expect } from "vitest"; +import { + LiquidityHeatmapExportService, + type LiquiditySnapshotRow, +} from "../../src/services/liquidityHeatmapExport.service.js"; + +describe("LiquidityHeatmapExportService", () => { + const service = new LiquidityHeatmapExportService(); + + const rows: LiquiditySnapshotRow[] = [ + { time: "2026-08-01T01:15:00.000Z", symbol: "USDC", dex: "StellarX", tvl_usd: 100 }, + { time: "2026-08-01T01:45:00.000Z", symbol: "USDC", dex: "StellarX", tvl_usd: 50 }, + { time: "2026-08-01T01:30:00.000Z", symbol: "USDC", dex: "Soroswap", tvl_usd: 25 }, + { time: "2026-08-02T05:00:00.000Z", symbol: "EURC", dex: "StellarX", tvl_usd: 10 }, + ]; + + describe("buildHeatmap", () => { + it("buckets rows by day by default and sums TVL per symbol/bucket", () => { + const heatmap = service.buildHeatmap(rows); + + expect(heatmap.interval).toBe("day"); + expect(heatmap.axis.buckets).toEqual(["2026-08-01", "2026-08-02"]); + expect(heatmap.axis.symbols).toEqual(["EURC", "USDC"]); + expect(heatmap.matrix.USDC["2026-08-01"]).toBe(175); + expect(heatmap.matrix.EURC["2026-08-02"]).toBe(10); + }); + + it("buckets rows by hour when requested, keeping dexes separate as cells", () => { + const heatmap = service.buildHeatmap(rows, { interval: "hour" }); + + expect(heatmap.axis.buckets).toEqual([ + "2026-08-01T01:00:00.000Z", + "2026-08-02T05:00:00.000Z", + ]); + // All three USDC rows fall in the same hour bucket, across two dexes. + const usdcCells = heatmap.cells.filter((c) => c.symbol === "USDC"); + expect(usdcCells).toHaveLength(2); + expect(heatmap.matrix.USDC["2026-08-01T01:00:00.000Z"]).toBe(175); + }); + + it("returns an empty heatmap for no rows", () => { + const heatmap = service.buildHeatmap([]); + expect(heatmap.axis.buckets).toEqual([]); + expect(heatmap.cells).toEqual([]); + expect(heatmap.matrix).toEqual({}); + }); + }); + + describe("toCsv", () => { + it("renders a symbol x bucket matrix with zero-filled gaps", () => { + const heatmap = service.buildHeatmap(rows); + const csv = service.toCsv(heatmap); + const lines = csv.split("\n"); + + expect(lines[0]).toBe("symbol,2026-08-01,2026-08-02"); + const eurcRow = lines.find((l) => l.startsWith("EURC")); + expect(eurcRow).toBe("EURC,0,10"); + }); + }); +});