From 28196727e9e153d1de2cc0fdaafbae3eb62b235f Mon Sep 17 00:00:00 2001 From: unknown Date: Mon, 20 Jul 2026 16:21:42 +0100 Subject: [PATCH] protocol health --- apps/backend/src/app.module.ts | 2 + .../protocol-health-metric.interface.ts | 65 ++++ .../protocol-health.controller.ts | 29 ++ .../protocol-health/protocol-health.module.ts | 15 + .../protocol-health.service.ts | 310 ++++++++++++++++++ .../protocol-health.repository.ts | 75 +++++ prisma/schema.prisma | 36 ++ 7 files changed, 532 insertions(+) create mode 100644 apps/backend/src/modules/protocol-health/interfaces/protocol-health-metric.interface.ts create mode 100644 apps/backend/src/modules/protocol-health/protocol-health.controller.ts create mode 100644 apps/backend/src/modules/protocol-health/protocol-health.module.ts create mode 100644 apps/backend/src/modules/protocol-health/protocol-health.service.ts create mode 100644 apps/backend/src/modules/protocol-health/repositories/protocol-health.repository.ts diff --git a/apps/backend/src/app.module.ts b/apps/backend/src/app.module.ts index e4d4f27..6ffd608 100644 --- a/apps/backend/src/app.module.ts +++ b/apps/backend/src/app.module.ts @@ -11,6 +11,7 @@ import { ChainsModule } from './modules/chains/chains.module'; import { RiskAnalyzerModule } from './modules/soroban/risk/risk-analyzer.module'; import { NotesModule } from './modules/cases/notes/notes.module'; import { AlertsModule } from './modules/alerts/alerts.module'; +import { ProtocolHealthModule } from './modules/protocol-health/protocol-health.module'; import { ReportsModule } from '../../../src/modules/reports/reports.module'; import { ProfileModule } from './modules/profile/profile.module'; @@ -34,6 +35,7 @@ import { IncidentsModule } from './modules/incidents/incidents.module'; AlertsModule, ProfileModule, IncidentsModule, + ProtocolHealthModule, ], controllers: [AppController], }) diff --git a/apps/backend/src/modules/protocol-health/interfaces/protocol-health-metric.interface.ts b/apps/backend/src/modules/protocol-health/interfaces/protocol-health-metric.interface.ts new file mode 100644 index 0000000..5a8ab20 --- /dev/null +++ b/apps/backend/src/modules/protocol-health/interfaces/protocol-health-metric.interface.ts @@ -0,0 +1,65 @@ +export interface ProtocolHealthMetric { + chainId: string; + timestamp: Date; + transactionCount: number; + failedTransactionCount: number; + contractCallCount: number; + contractDeployCount: number; + uniqueSenders: number; + monitorHealthy: boolean; +} + +export interface MetricsWindow { + chainId: string; + transactionCount: number; + failedTransactionCount: number; + contractCallCount: number; + contractDeployCount: number; + uniqueSenders: Set; + eventCount: number; +} + +export interface AnomalyDetectionThresholds { + throughputDropPercent: number; + failedTxRatePercent: number; + contractCallSpikeMultiplier: number; + contractDeploySpikeMultiplier: number; + eventGapMinutes: number; + networkErrorCount: number; +} + +export const DEFAULT_ANOMALY_THRESHOLDS: AnomalyDetectionThresholds = { + throughputDropPercent: 50, + failedTxRatePercent: 15, + contractCallSpikeMultiplier: 3, + contractDeploySpikeMultiplier: 5, + eventGapMinutes: 5, + networkErrorCount: 10, +}; + +export interface HealthSnapshotResponse { + chainId: string; + status: 'healthy' | 'degraded' | 'unhealthy'; + timestamp: string; + metrics: { + transactionCount: number; + failedTransactionCount: number; + successRate: number; + contractCallCount: number; + contractDeployCount: number; + uniqueSenders: number; + monitorHealthy: boolean; + }; + anomalies: string[]; +} + +export interface ProtocolHealthAlertResponse { + id: string; + chainId: string; + alertType: string; + severity: string; + status: string; + message: string; + metadata?: unknown; + createdAt: string; +} diff --git a/apps/backend/src/modules/protocol-health/protocol-health.controller.ts b/apps/backend/src/modules/protocol-health/protocol-health.controller.ts new file mode 100644 index 0000000..2af69fa --- /dev/null +++ b/apps/backend/src/modules/protocol-health/protocol-health.controller.ts @@ -0,0 +1,29 @@ +import { Controller, Get, HttpCode, HttpStatus, Param, Query } from '@nestjs/common'; +import { ProtocolHealthService } from './protocol-health.service'; +import { + HealthSnapshotResponse, + ProtocolHealthAlertResponse, +} from './interfaces/protocol-health-metric.interface'; + +@Controller('protocol-health') +export class ProtocolHealthController { + constructor(private readonly protocolHealthService: ProtocolHealthService) {} + + @Get() + @HttpCode(HttpStatus.OK) + async getAllChainsHealth(): Promise { + return this.protocolHealthService.getAllChainsHealth(); + } + + @Get(':chainId') + @HttpCode(HttpStatus.OK) + async getChainHealth(@Param('chainId') chainId: string): Promise { + return this.protocolHealthService.getHealthSnapshot(chainId); + } + + @Get('alerts') + @HttpCode(HttpStatus.OK) + async getAlerts(@Query('chainId') chainId?: string): Promise { + return this.protocolHealthService.getAlerts(chainId); + } +} diff --git a/apps/backend/src/modules/protocol-health/protocol-health.module.ts b/apps/backend/src/modules/protocol-health/protocol-health.module.ts new file mode 100644 index 0000000..21c6740 --- /dev/null +++ b/apps/backend/src/modules/protocol-health/protocol-health.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common'; +import { ProtocolHealthController } from './protocol-health.controller'; +import { ProtocolHealthService } from './protocol-health.service'; +import { ProtocolHealthRepository } from './repositories/protocol-health.repository'; +import { ChainsModule } from '../../modules/chains/chains.module'; +import { NotificationsModule } from '../notifications/notifications.module'; +import { IncidentsModule } from '../incidents/incidents.module'; + +@Module({ + imports: [ChainsModule, NotificationsModule, IncidentsModule], + controllers: [ProtocolHealthController], + providers: [ProtocolHealthService, ProtocolHealthRepository], + exports: [ProtocolHealthService], +}) +export class ProtocolHealthModule {} diff --git a/apps/backend/src/modules/protocol-health/protocol-health.service.ts b/apps/backend/src/modules/protocol-health/protocol-health.service.ts new file mode 100644 index 0000000..76ad3e7 --- /dev/null +++ b/apps/backend/src/modules/protocol-health/protocol-health.service.ts @@ -0,0 +1,310 @@ +import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common'; +import { ChainRegistryService } from '../../modules/chains/chain-registry.service'; +import { NormalizedChainEvent } from '../../modules/chains/interfaces/normalized-chain-event.interface'; +import { NotificationsService } from '../../modules/notifications/notifications.service'; +import { IncidentsService } from '../incidents/services/incidents.service'; +import { ProtocolHealthRepository } from './repositories/protocol-health.repository'; +import { + ProtocolHealthMetric, + MetricsWindow, + DEFAULT_ANOMALY_THRESHOLDS, + AnomalyDetectionThresholds, + HealthSnapshotResponse, +} from './interfaces/protocol-health-metric.interface'; + +@Injectable() +export class ProtocolHealthService implements OnModuleInit, OnModuleDestroy { + private readonly logger = new Logger(ProtocolHealthService.name); + private readonly collectionIntervalMs = 60_000; + private readonly baselineWindowMs = 60 * 60 * 1000; + private collectionTimer: NodeJS.Timeout | null = null; + private readonly windows = new Map(); + private readonly baselines = new Map(); + private readonly lastEventTimes = new Map(); + private readonly thresholds: AnomalyDetectionThresholds; + + constructor( + private readonly chainRegistry: ChainRegistryService, + private readonly notifications: NotificationsService, + private readonly incidents: IncidentsService, + private readonly repository: ProtocolHealthRepository, + thresholds?: Partial, + ) { + this.thresholds = { ...DEFAULT_ANOMALY_THRESHOLDS, ...thresholds }; + } + + async onModuleInit(): Promise { + await this.subscribeToChainEvents(); + this.startCollectionLoop(); + this.logger.log('ProtocolHealthService: initialized and collecting metrics'); + } + + onModuleDestroy(): void { + if (this.collectionTimer) { + clearInterval(this.collectionTimer); + this.collectionTimer = null; + } + } + + async getHealthSnapshot(chainId: string): Promise { + const latest = await this.repository.findLatestMetric(chainId); + if (!latest) { + return { + chainId, + status: 'degraded', + timestamp: new Date().toISOString(), + metrics: { + transactionCount: 0, + failedTransactionCount: 0, + successRate: 0, + contractCallCount: 0, + contractDeployCount: 0, + uniqueSenders: 0, + monitorHealthy: true, + }, + anomalies: ['No metrics collected yet'], + }; + } + + const anomalies = await this.detectAnomalies(chainId); + const total = latest.transactionCount; + const successRate = total > 0 ? ((total - latest.failedTransactionCount) / total) * 100 : 100; + + let status: 'healthy' | 'degraded' | 'unhealthy' = 'healthy'; + if (!latest.monitorHealthy || anomalies.length > 0) { + status = anomalies.some(a => a.includes('critical') || a.includes('network')) + ? 'unhealthy' + : 'degraded'; + } + + return { + chainId, + status, + timestamp: latest.timestamp.toISOString(), + metrics: { + transactionCount: latest.transactionCount, + failedTransactionCount: latest.failedTransactionCount, + successRate: Number(successRate.toFixed(2)), + contractCallCount: latest.contractCallCount, + contractDeployCount: latest.contractDeployCount, + uniqueSenders: latest.uniqueSenders, + monitorHealthy: latest.monitorHealthy, + }, + anomalies, + }; + } + + async getAllChainsHealth(): Promise { + const chainIds = this.chainRegistry.getChainIds(); + return Promise.all(chainIds.map(id => this.getHealthSnapshot(id))); + } + + async getAlerts(chainId?: string) { + const alerts = await this.repository.findOpenAlerts(chainId); + return alerts.map(alert => ({ + ...alert, + createdAt: alert.createdAt.toISOString(), + metadata: alert.metadata ?? undefined, + })); + } + + private async subscribeToChainEvents(): Promise { + await this.chainRegistry.subscribeAll((event: NormalizedChainEvent) => { + this.handleChainEvent(event); + }); + } + + private handleChainEvent(event: NormalizedChainEvent): void { + const { chainId, eventType } = event; + this.lastEventTimes.set(chainId, Date.now()); + + let window = this.windows.get(chainId); + if (!window) { + window = { + chainId, + transactionCount: 0, + failedTransactionCount: 0, + contractCallCount: 0, + contractDeployCount: 0, + uniqueSenders: new Set(), + eventCount: 0, + }; + this.windows.set(chainId, window); + } + + window.transactionCount += 1; + window.eventCount += 1; + + if (event.from) { + window.uniqueSenders.add(event.from); + } + + if (eventType === 'contract_call') { + window.contractCallCount += 1; + } else if (eventType === 'contract_deploy') { + window.contractDeployCount += 1; + } + + if (eventType === 'failed' || eventType === 'error') { + window.failedTransactionCount += 1; + } + } + + private startCollectionLoop(): void { + this.collectionTimer = setInterval(() => { + this.collectAndAnalyze().catch(err => { + this.logger.error(`Protocol health collection error: ${String(err)}`); + }); + }, this.collectionIntervalMs); + } + + private async collectAndAnalyze(): Promise { + const chainIds = this.chainRegistry.getChainIds(); + + for (const chainId of chainIds) { + const window = this.windows.get(chainId); + const monitor = this.chainRegistry.getMonitor(chainId); + const monitorHealthy = monitor ? await monitor.isHealthy().catch(() => false) : true; + + if (!window) continue; + + const metric: ProtocolHealthMetric = { + chainId, + timestamp: new Date(), + transactionCount: window.transactionCount, + failedTransactionCount: window.failedTransactionCount, + contractCallCount: window.contractCallCount, + contractDeployCount: window.contractDeployCount, + uniqueSenders: window.uniqueSenders.size, + monitorHealthy, + }; + + await this.repository.createMetric(metric); + + let baselines = this.baselines.get(chainId); + if (!baselines) { + baselines = []; + this.baselines.set(chainId, baselines); + } + baselines.push(metric); + if (baselines.length > 60) baselines.shift(); + + const anomalies = await this.detectAnomalies(chainId); + if (anomalies.length > 0) { + await this.handleAnomalies(chainId, anomalies); + } + + window.transactionCount = 0; + window.failedTransactionCount = 0; + window.contractCallCount = 0; + window.contractDeployCount = 0; + window.uniqueSenders = new Set(); + window.eventCount = 0; + } + } + + private async detectAnomalies(chainId: string): Promise { + const anomalies: string[] = []; + const baselines = this.baselines.get(chainId) ?? []; + if (baselines.length < 2) return anomalies; + + const current = baselines[baselines.length - 1]; + const historical = baselines.slice(0, -1); + const avgThroughput = + historical.reduce((s, m) => s + m.transactionCount, 0) / historical.length; + const avgContractCalls = + historical.reduce((s, m) => s + m.contractCallCount, 0) / historical.length; + const avgContractDeploys = + historical.reduce((s, m) => s + m.contractDeployCount, 0) / historical.length; + + if ( + avgThroughput > 0 && + current.transactionCount < avgThroughput * (1 - this.thresholds.throughputDropPercent / 100) + ) { + const drop = ((1 - current.transactionCount / avgThroughput) * 100).toFixed(0); + anomalies.push( + `Transaction throughput dropped ${drop}% below baseline (current: ${current.transactionCount}, baseline: ${avgThroughput.toFixed(1)})`, + ); + } + + if ( + current.transactionCount > 0 && + current.failedTransactionCount / current.transactionCount > + this.thresholds.failedTxRatePercent / 100 + ) { + const rate = ((current.failedTransactionCount / current.transactionCount) * 100).toFixed(1); + anomalies.push(`Failed transaction rate elevated at ${rate}%`); + } + + if ( + avgContractCalls > 0 && + current.contractCallCount > avgContractCalls * this.thresholds.contractCallSpikeMultiplier + ) { + const spike = ((current.contractCallCount / avgContractCalls) * 100).toFixed(0); + anomalies.push(`Contract call activity spiked ${spike}% above baseline`); + } + + if ( + avgContractDeploys > 0 && + current.contractDeployCount > + avgContractDeploys * this.thresholds.contractDeploySpikeMultiplier + ) { + const spike = ((current.contractDeployCount / avgContractDeploys) * 100).toFixed(0); + anomalies.push(`Contract deployment activity spiked ${spike}% above baseline`); + } else if (avgContractDeploys === 0 && current.contractDeployCount > 2) { + anomalies.push( + `Unexpected contract deployments detected: ${current.contractDeployCount} new contracts`, + ); + } + + if (!current.monitorHealthy) { + anomalies.push('Chain monitor connectivity lost — network instability suspected'); + } + + const lastEvent = this.lastEventTimes.get(chainId); + if ( + lastEvent && + Date.now() - lastEvent > this.thresholds.eventGapMinutes * 60_000 && + avgThroughput > 0 + ) { + anomalies.push( + `No events received for ${this.thresholds.eventGapMinutes} minutes — possible network stall`, + ); + } + + return anomalies; + } + + private async handleAnomalies(chainId: string, anomalies: string[]): Promise { + const severity = anomalies.some(a => a.includes('connectivity') || a.includes('stall')) + ? 'high' + : 'medium'; + + for (const anomaly of anomalies) { + const alert = await this.repository.createAlert({ + chainId, + alertType: 'protocol_anomaly', + severity, + message: anomaly, + metadata: { + chainId, + anomalies, + detectedAt: new Date().toISOString(), + }, + }); + + this.logger.warn(`ProtocolHealthAlert [${chainId}] ${severity}: ${anomaly}`); + + this.notifications + .sendAlert({ + title: `Protocol Anomaly Detected: ${chainId}`, + message: anomaly, + severity: severity as 'low' | 'medium' | 'high' | 'critical', + metadata: { alertId: alert.id, chainId }, + }) + .catch(err => { + this.logger.error(`Failed to send protocol health notification: ${String(err)}`); + }); + } + } +} diff --git a/apps/backend/src/modules/protocol-health/repositories/protocol-health.repository.ts b/apps/backend/src/modules/protocol-health/repositories/protocol-health.repository.ts new file mode 100644 index 0000000..d46c747 --- /dev/null +++ b/apps/backend/src/modules/protocol-health/repositories/protocol-health.repository.ts @@ -0,0 +1,75 @@ +import { Injectable } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import { PrismaService } from '../../../database/prisma.service'; +import { ProtocolHealthMetric } from '../interfaces/protocol-health-metric.interface'; + +@Injectable() +export class ProtocolHealthRepository { + constructor(private readonly prisma: PrismaService) {} + + async createMetric(metric: ProtocolHealthMetric) { + return this.prisma.protocolHealthMetric.create({ + data: { + chainId: metric.chainId, + timestamp: metric.timestamp, + transactionCount: metric.transactionCount, + failedTransactionCount: metric.failedTransactionCount, + contractCallCount: metric.contractCallCount, + contractDeployCount: metric.contractDeployCount, + uniqueSenders: metric.uniqueSenders, + monitorHealthy: metric.monitorHealthy, + }, + }); + } + + async findRecentMetrics(chainId: string, since: Date) { + return this.prisma.protocolHealthMetric.findMany({ + where: { + chainId, + timestamp: { gte: since }, + }, + orderBy: { timestamp: 'asc' }, + }); + } + + async findLatestMetric(chainId: string) { + return this.prisma.protocolHealthMetric.findFirst({ + where: { chainId }, + orderBy: { timestamp: 'desc' }, + }); + } + + async createAlert(data: { + chainId: string; + alertType: string; + severity: string; + message: string; + metadata?: Prisma.InputJsonValue; + }) { + return this.prisma.protocolHealthAlert.create({ + data, + }); + } + + async findOpenAlerts(chainId?: string) { + return this.prisma.protocolHealthAlert.findMany({ + where: { + status: 'open', + ...(chainId ? { chainId } : {}), + }, + orderBy: { createdAt: 'desc' }, + }); + } + + async updateAlertStatus(id: string, status: string, acknowledgedBy?: string) { + return this.prisma.protocolHealthAlert.update({ + where: { id }, + data: { + status, + ...(status === 'acknowledged' + ? { acknowledgedAt: new Date(), acknowledgedBy: acknowledgedBy ?? 'system' } + : {}), + }, + }); + } +} diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 39bab3c..3ff7954 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -225,4 +225,40 @@ model IncidentAuditLog { @@index([incidentId]) @@map("incident_audit_logs") +} + +// --------------------------------------------------------------------------- +// Protocol Health Monitoring +// --------------------------------------------------------------------------- + +model ProtocolHealthMetric { + id String @id @default(cuid()) + chainId String + timestamp DateTime @default(now()) + transactionCount Int @default(0) + failedTransactionCount Int @default(0) + contractCallCount Int @default(0) + contractDeployCount Int @default(0) + uniqueSenders Int @default(0) + monitorHealthy Boolean @default(true) + + @@index([chainId, timestamp]) + @@map("protocol_health_metrics") +} + +model ProtocolHealthAlert { + id String @id @default(cuid()) + chainId String + alertType String + severity String @default("medium") + status String @default("open") + message String + metadata Json? + acknowledgedAt DateTime? + acknowledgedBy String? + createdAt DateTime @default(now()) + + @@index([chainId, status]) + @@index([status]) + @@map("protocol_health_alerts") } \ No newline at end of file