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
151 changes: 151 additions & 0 deletions backend/src/api/routes/addressLabels.routes.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
}
);
}
44 changes: 44 additions & 0 deletions backend/src/api/routes/bridgeComparisonReport.routes.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
});
}
106 changes: 106 additions & 0 deletions backend/src/api/routes/chartSamplingControls.routes.ts
Original file line number Diff line number Diff line change
@@ -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();
}
);
}
63 changes: 63 additions & 0 deletions backend/src/api/routes/liquidityHeatmapExport.routes.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
});
}
17 changes: 17 additions & 0 deletions backend/src/api/routes/route-groups/analytics-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,13 @@ import { metricsAggregationRoutes } from "../metricsAggregation.routes.js";
import { savedMetricsRoutes } from "../savedMetrics.routes.js";
import { externalRateLimitMetricsRoutes } from "../externalRateLimitMetrics.routes.js";
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";
import { operationalIntelligenceRoutes } from "../operationalIntelligence.routes.js";

export async function registerAnalyticsRoutes(server: FastifyInstance): Promise<void> {
Expand All @@ -23,4 +30,14 @@ export async function registerAnalyticsRoutes(server: FastifyInstance): Promise<
server.register(operationalIntelligenceRoutes, {
prefix: "/api/v1/operational-intelligence",
});

// #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",
});
}
Loading
Loading