diff --git a/src/reliability-score/1700000000000-CreateBridgeReliabilityTables.ts b/src/reliability-score/1700000000000-CreateBridgeReliabilityTables.ts new file mode 100644 index 0000000..db9e6cd --- /dev/null +++ b/src/reliability-score/1700000000000-CreateBridgeReliabilityTables.ts @@ -0,0 +1,79 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CreateBridgeReliabilityTables1700000000000 implements MigrationInterface { + name = 'CreateBridgeReliabilityTables1700000000000'; + + public async up(queryRunner: QueryRunner): Promise { + // ── Enums ──────────────────────────────────────────────────────────────── + await queryRunner.query(` + CREATE TYPE "public"."transaction_outcome_enum" AS ENUM( + 'SUCCESS', 'FAILED', 'TIMEOUT', 'CANCELLED' + ) + `); + + await queryRunner.query(` + CREATE TYPE "public"."reliability_tier_enum" AS ENUM( + 'HIGH', 'MEDIUM', 'LOW' + ) + `); + + // ── bridge_transaction_events ───────────────────────────────────────────── + await queryRunner.query(` + CREATE TABLE "bridge_transaction_events" ( + "id" UUID NOT NULL DEFAULT uuid_generate_v4(), + "bridgeName" VARCHAR(100) NOT NULL, + "sourceChain" VARCHAR(50) NOT NULL, + "destinationChain" VARCHAR(50) NOT NULL, + "outcome" "public"."transaction_outcome_enum" NOT NULL, + "transactionHash" VARCHAR(255), + "failureReason" TEXT, + "durationMs" INTEGER NOT NULL DEFAULT 0, + "createdAt" TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT "PK_bridge_transaction_events" PRIMARY KEY ("id") + ) + `); + + await queryRunner.query(` + CREATE INDEX "IDX_bte_bridge_name" + ON "bridge_transaction_events" ("bridgeName") + `); + + await queryRunner.query(` + CREATE INDEX "IDX_bte_route_created" + ON "bridge_transaction_events" ("bridgeName", "sourceChain", "destinationChain", "createdAt") + `); + + // ── bridge_reliability_metrics ──────────────────────────────────────────── + await queryRunner.query(` + CREATE TABLE "bridge_reliability_metrics" ( + "id" UUID NOT NULL DEFAULT uuid_generate_v4(), + "bridgeName" VARCHAR(100) NOT NULL, + "sourceChain" VARCHAR(50) NOT NULL, + "destinationChain" VARCHAR(50) NOT NULL, + "totalAttempts" INTEGER NOT NULL DEFAULT 0, + "successfulTransfers" INTEGER NOT NULL DEFAULT 0, + "failedTransfers" INTEGER NOT NULL DEFAULT 0, + "timeoutCount" INTEGER NOT NULL DEFAULT 0, + "reliabilityPercent" DECIMAL(5,2) NOT NULL DEFAULT 0, + "reliabilityScore" DECIMAL(5,2) NOT NULL DEFAULT 0, + "reliabilityTier" "public"."reliability_tier_enum" NOT NULL DEFAULT 'LOW', + "windowConfig" JSONB, + "lastComputedAt" TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT "PK_bridge_reliability_metrics" PRIMARY KEY ("id"), + CONSTRAINT "UQ_bridge_route" UNIQUE ("bridgeName", "sourceChain", "destinationChain") + ) + `); + + await queryRunner.query(` + CREATE INDEX "IDX_brm_bridge_name" + ON "bridge_reliability_metrics" ("bridgeName") + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS "bridge_reliability_metrics"`); + await queryRunner.query(`DROP TABLE IF EXISTS "bridge_transaction_events"`); + await queryRunner.query(`DROP TYPE IF EXISTS "public"."reliability_tier_enum"`); + await queryRunner.query(`DROP TYPE IF EXISTS "public"."transaction_outcome_enum"`); + } +} diff --git a/src/reliability-score/bridge-reliability-metric.entity.ts b/src/reliability-score/bridge-reliability-metric.entity.ts new file mode 100644 index 0000000..b50b41b --- /dev/null +++ b/src/reliability-score/bridge-reliability-metric.entity.ts @@ -0,0 +1,56 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + UpdateDateColumn, + Index, + Unique, +} from 'typeorm'; +import { ReliabilityTier } from '../enums/reliability.enum'; + +@Entity('bridge_reliability_metrics') +@Unique(['bridgeName', 'sourceChain', 'destinationChain']) +export class BridgeReliabilityMetric { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ length: 100 }) + @Index() + bridgeName: string; + + @Column({ length: 50 }) + sourceChain: string; + + @Column({ length: 50 }) + destinationChain: string; + + @Column({ type: 'int', default: 0 }) + totalAttempts: number; + + @Column({ type: 'int', default: 0 }) + successfulTransfers: number; + + @Column({ type: 'int', default: 0 }) + failedTransfers: number; + + @Column({ type: 'int', default: 0 }) + timeoutCount: number; + + @Column({ type: 'decimal', precision: 5, scale: 2, default: 0 }) + reliabilityPercent: number; + + @Column({ type: 'decimal', precision: 5, scale: 2, default: 0 }) + reliabilityScore: number; + + @Column({ type: 'enum', enum: ReliabilityTier, default: ReliabilityTier.LOW }) + reliabilityTier: ReliabilityTier; + + @Column({ type: 'jsonb', nullable: true }) + windowConfig: { + mode: string; + size: number; + } | null; + + @UpdateDateColumn() + lastComputedAt: Date; +} diff --git a/src/reliability-score/bridge-reliability.controller.ts b/src/reliability-score/bridge-reliability.controller.ts new file mode 100644 index 0000000..13ebece --- /dev/null +++ b/src/reliability-score/bridge-reliability.controller.ts @@ -0,0 +1,83 @@ +import { + Body, + Controller, + Get, + HttpCode, + HttpStatus, + Post, + Query, +} from '@nestjs/common'; +import { + ApiBody, + ApiOkResponse, + ApiOperation, + ApiTags, +} from '@nestjs/swagger'; +import { BridgeReliabilityService } from './bridge-reliability.service'; +import { + BridgeReliabilityResponseDto, + GetReliabilityDto, + RecordBridgeEventDto, + ReliabilityRankingFactorDto, +} from './dto/reliability.dto'; +import { BridgeReliabilityMetric } from './entities/bridge-reliability-metric.entity'; + +@ApiTags('Bridge Reliability') +@Controller('bridge-reliability') +export class BridgeReliabilityController { + constructor(private readonly reliabilityService: BridgeReliabilityService) {} + + /** + * Record a bridge transaction outcome (called internally by bridge adapters). + */ + @Post('events') + @HttpCode(HttpStatus.CREATED) + @ApiOperation({ summary: 'Record a bridge transaction outcome' }) + @ApiBody({ type: RecordBridgeEventDto }) + async recordEvent(@Body() dto: RecordBridgeEventDto) { + return this.reliabilityService.recordEvent(dto); + } + + /** + * Get reliability score for a specific bridge route. + */ + @Get() + @ApiOperation({ summary: 'Get reliability score for a bridge route' }) + @ApiOkResponse({ type: BridgeReliabilityResponseDto }) + async getReliability( + @Query() dto: GetReliabilityDto, + ): Promise { + return this.reliabilityService.getReliability(dto); + } + + /** + * Get all cached reliability metrics (for admin / ranking engine). + */ + @Get('all') + @ApiOperation({ summary: 'List all bridge reliability metrics (admin)' }) + async getAllMetrics(): Promise { + return this.reliabilityService.getAllMetrics(); + } + + /** + * Get ranking adjustment factors for all bridges on a route. + * Called by Smart Bridge Ranking engine (Issue #5). + */ + @Get('ranking-factors') + @ApiOperation({ + summary: 'Get reliability ranking factors for a route', + description: 'Returns reliability-adjusted scores for all bridges on a route.', + }) + async getRankingFactors( + @Query('sourceChain') sourceChain: string, + @Query('destinationChain') destinationChain: string, + @Query('threshold') threshold?: number, + @Query('ignoreReliability') ignoreReliability?: boolean, + ): Promise { + return this.reliabilityService.getBulkReliabilityFactors( + sourceChain, + destinationChain, + { threshold, ignoreReliability }, + ); + } +} diff --git a/src/reliability-score/bridge-reliability.module.ts b/src/reliability-score/bridge-reliability.module.ts new file mode 100644 index 0000000..1f44944 --- /dev/null +++ b/src/reliability-score/bridge-reliability.module.ts @@ -0,0 +1,17 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { BridgeTransactionEvent } from './entities/bridge-transaction-event.entity'; +import { BridgeReliabilityMetric } from './entities/bridge-reliability-metric.entity'; +import { BridgeReliabilityService } from './bridge-reliability.service'; +import { BridgeReliabilityController } from './bridge-reliability.controller'; +import { ReliabilityCalculatorService } from './reliability-calculator.service'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([BridgeTransactionEvent, BridgeReliabilityMetric]), + ], + controllers: [BridgeReliabilityController], + providers: [BridgeReliabilityService, ReliabilityCalculatorService], + exports: [BridgeReliabilityService, ReliabilityCalculatorService], +}) +export class BridgeReliabilityModule {} diff --git a/src/reliability-score/bridge-reliability.service.spec.ts b/src/reliability-score/bridge-reliability.service.spec.ts new file mode 100644 index 0000000..1d963ed --- /dev/null +++ b/src/reliability-score/bridge-reliability.service.spec.ts @@ -0,0 +1,386 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { BridgeReliabilityService } from './bridge-reliability.service'; +import { ReliabilityCalculatorService } from './reliability-calculator.service'; +import { BridgeTransactionEvent } from './entities/bridge-transaction-event.entity'; +import { BridgeReliabilityMetric } from './entities/bridge-reliability-metric.entity'; +import { TransactionOutcome, WindowMode, ReliabilityTier } from './enums/reliability.enum'; +import { RecordBridgeEventDto, GetReliabilityDto } from './dto/reliability.dto'; + +// ─── Mock Factory ────────────────────────────────────────────────────────────── + +type MockRepo = Partial, jest.Mock>>; + +const createMockRepo = (): MockRepo => ({ + create: jest.fn(), + save: jest.fn(), + find: jest.fn(), + findOne: jest.fn(), + update: jest.fn(), +}); + +// ─── Helpers ─────────────────────────────────────────────────────────────────── + +const makeEvent = (outcome: TransactionOutcome): Partial => ({ + outcome, + createdAt: new Date(), +}); + +const makeEvents = ( + successCount: number, + failCount: number, + timeoutCount: number, + cancelledCount = 0, +): Partial[] => [ + ...Array(successCount).fill(makeEvent(TransactionOutcome.SUCCESS)), + ...Array(failCount).fill(makeEvent(TransactionOutcome.FAILED)), + ...Array(timeoutCount).fill(makeEvent(TransactionOutcome.TIMEOUT)), + ...Array(cancelledCount).fill(makeEvent(TransactionOutcome.CANCELLED)), +]; + +// ─── Tests ───────────────────────────────────────────────────────────────────── + +describe('BridgeReliabilityService', () => { + let service: BridgeReliabilityService; + let eventRepo: MockRepo; + let metricRepo: MockRepo; + + const baseRoute = { + bridgeName: 'Stargate', + sourceChain: 'ethereum', + destinationChain: 'polygon', + }; + + beforeEach(async () => { + eventRepo = createMockRepo(); + metricRepo = createMockRepo(); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + BridgeReliabilityService, + ReliabilityCalculatorService, + { + provide: getRepositoryToken(BridgeTransactionEvent), + useValue: eventRepo, + }, + { + provide: getRepositoryToken(BridgeReliabilityMetric), + useValue: metricRepo, + }, + ], + }).compile(); + + service = module.get(BridgeReliabilityService); + }); + + afterEach(() => jest.clearAllMocks()); + + // ─── recordEvent ────────────────────────────────────────────────────────── + + describe('recordEvent', () => { + const dto: RecordBridgeEventDto = { + ...baseRoute, + outcome: TransactionOutcome.SUCCESS, + transactionHash: '0xabc', + durationMs: 5000, + }; + + it('creates and saves the event', async () => { + const created = { id: 'uuid-1', ...dto }; + eventRepo.create!.mockReturnValue(created); + eventRepo.save!.mockResolvedValue(created); + metricRepo.update!.mockResolvedValue({ affected: 0 }); + + const result = await service.recordEvent(dto); + + expect(eventRepo.create).toHaveBeenCalledWith( + expect.objectContaining({ + bridgeName: 'Stargate', + outcome: TransactionOutcome.SUCCESS, + }), + ); + expect(eventRepo.save).toHaveBeenCalledWith(created); + expect(result).toEqual(created); + }); + + it('records a failed transfer', async () => { + const failDto: RecordBridgeEventDto = { + ...baseRoute, + outcome: TransactionOutcome.FAILED, + failureReason: 'Insufficient liquidity', + }; + + eventRepo.create!.mockReturnValue({ id: 'uuid-2', ...failDto }); + eventRepo.save!.mockResolvedValue({ id: 'uuid-2', ...failDto }); + metricRepo.update!.mockResolvedValue({ affected: 0 }); + + const result = await service.recordEvent(failDto); + expect(result.outcome).toBe(TransactionOutcome.FAILED); + }); + + it('records a timeout event', async () => { + const timeoutDto: RecordBridgeEventDto = { + ...baseRoute, + outcome: TransactionOutcome.TIMEOUT, + failureReason: 'Bridge response timed out after 30s', + }; + + eventRepo.create!.mockReturnValue({ id: 'uuid-3', ...timeoutDto }); + eventRepo.save!.mockResolvedValue({ id: 'uuid-3', ...timeoutDto }); + metricRepo.update!.mockResolvedValue({ affected: 0 }); + + const result = await service.recordEvent(timeoutDto); + expect(result.outcome).toBe(TransactionOutcome.TIMEOUT); + }); + + it('does not throw for cancelled transactions', async () => { + const cancelDto: RecordBridgeEventDto = { + ...baseRoute, + outcome: TransactionOutcome.CANCELLED, + }; + + eventRepo.create!.mockReturnValue({ id: 'uuid-4', ...cancelDto }); + eventRepo.save!.mockResolvedValue({ id: 'uuid-4', ...cancelDto }); + metricRepo.update!.mockResolvedValue({ affected: 0 }); + + await expect(service.recordEvent(cancelDto)).resolves.not.toThrow(); + }); + }); + + // ─── getReliability - Transaction Count Window ───────────────────────────── + + describe('getReliability (TRANSACTION_COUNT)', () => { + const dto: GetReliabilityDto = { + ...baseRoute, + windowMode: WindowMode.TRANSACTION_COUNT, + windowSize: 100, + }; + + const mockMetric = { + ...baseRoute, + reliabilityScore: 97, + lastComputedAt: new Date(), + } as BridgeReliabilityMetric; + + it('returns HIGH tier for >= 95% success rate', async () => { + // 97 successes, 2 failed, 1 timeout = 97% + eventRepo.find!.mockResolvedValue(makeEvents(97, 2, 1)); + metricRepo.findOne!.mockResolvedValueOnce(null).mockResolvedValue(mockMetric); + metricRepo.create!.mockReturnValue({} as any); + metricRepo.save!.mockResolvedValue(mockMetric); + + const result = await service.getReliability(dto); + + expect(result.reliabilityPercent).toBeGreaterThanOrEqual(95); + expect(result.badge.tier).toBe(ReliabilityTier.HIGH); + }); + + it('returns MEDIUM tier for 85-94% success rate', async () => { + // 88 successes out of 100 = 88% + eventRepo.find!.mockResolvedValue(makeEvents(88, 12, 0)); + metricRepo.findOne!.mockResolvedValue(null); + metricRepo.create!.mockReturnValue({} as any); + metricRepo.save!.mockResolvedValue({ ...mockMetric, lastComputedAt: new Date() }); + + const result = await service.getReliability(dto); + + expect(result.reliabilityPercent).toBeGreaterThanOrEqual(85); + expect(result.reliabilityPercent).toBeLessThan(95); + expect(result.badge.tier).toBe(ReliabilityTier.MEDIUM); + }); + + it('returns LOW tier for < 85% success rate', async () => { + eventRepo.find!.mockResolvedValue(makeEvents(70, 20, 10)); + metricRepo.findOne!.mockResolvedValue(null); + metricRepo.create!.mockReturnValue({} as any); + metricRepo.save!.mockResolvedValue({ ...mockMetric, lastComputedAt: new Date() }); + + const result = await service.getReliability(dto); + + expect(result.reliabilityPercent).toBeLessThan(85); + expect(result.badge.tier).toBe(ReliabilityTier.LOW); + }); + + it('excludes CANCELLED transactions from reliability calculation', async () => { + // 97 success + 3 cancelled: cancelled excluded → total = 97, success = 97 → 100% + const events = makeEvents(97, 0, 0, 3); + eventRepo.find!.mockResolvedValue(events); + metricRepo.findOne!.mockResolvedValue(null); + metricRepo.create!.mockReturnValue({} as any); + metricRepo.save!.mockResolvedValue({ ...mockMetric, lastComputedAt: new Date() }); + + const result = await service.getReliability(dto); + + expect(result.successfulTransfers).toBe(97); + expect(result.totalAttempts).toBe(97); // cancelled excluded + }); + + it('returns 0 score when totalAttempts below minimum', async () => { + eventRepo.find!.mockResolvedValue(makeEvents(3, 0, 0)); + metricRepo.findOne!.mockResolvedValue(null); + metricRepo.create!.mockReturnValue({} as any); + metricRepo.save!.mockResolvedValue({ ...mockMetric, lastComputedAt: new Date() }); + + const result = await service.getReliability(dto); + + expect(result.reliabilityScore).toBe(0); + }); + }); + + // ─── getReliability - Time-Based Window ─────────────────────────────────── + + describe('getReliability (TIME_BASED)', () => { + it('queries events within the time window', async () => { + const dto: GetReliabilityDto = { + ...baseRoute, + windowMode: WindowMode.TIME_BASED, + windowSize: 7, + }; + + eventRepo.find!.mockResolvedValue(makeEvents(50, 2, 1)); + metricRepo.findOne!.mockResolvedValue(null); + metricRepo.create!.mockReturnValue({} as any); + metricRepo.save!.mockResolvedValue({ lastComputedAt: new Date() }); + + const result = await service.getReliability(dto); + + expect(eventRepo.find).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + createdAt: expect.anything(), // MoreThanOrEqual matcher + }), + }), + ); + expect(result.totalAttempts).toBe(53); + }); + }); + + // ─── Rolling Window Behavior ────────────────────────────────────────────── + + describe('rolling window behavior', () => { + it('limits results to windowSize for TRANSACTION_COUNT mode', async () => { + const dto: GetReliabilityDto = { + ...baseRoute, + windowMode: WindowMode.TRANSACTION_COUNT, + windowSize: 50, + }; + + // Provide more events than window size; service should slice to 50 + const events = makeEvents(80, 10, 10); // 100 events + eventRepo.find!.mockResolvedValue(events); + metricRepo.findOne!.mockResolvedValue(null); + metricRepo.create!.mockReturnValue({} as any); + metricRepo.save!.mockResolvedValue({ lastComputedAt: new Date() }); + + const result = await service.getReliability(dto); + + expect(result.totalAttempts).toBeLessThanOrEqual(50); + }); + + it('reliability changes when rolling window excludes old failures', async () => { + // Simulate that after removing old failures the score improves + const dtoSmallWindow: GetReliabilityDto = { + ...baseRoute, + windowMode: WindowMode.TRANSACTION_COUNT, + windowSize: 20, + }; + + // Recent 20: all successes + eventRepo.find!.mockResolvedValue(makeEvents(20, 0, 0)); + metricRepo.findOne!.mockResolvedValue(null); + metricRepo.create!.mockReturnValue({} as any); + metricRepo.save!.mockResolvedValue({ lastComputedAt: new Date() }); + + const result = await service.getReliability(dtoSmallWindow); + expect(result.reliabilityPercent).toBe(100); + }); + }); + + // ─── Ranking Engine Integration ─────────────────────────────────────────── + + describe('getReliabilityRankingFactor', () => { + it('returns factor with no penalty for reliable bridge', async () => { + metricRepo.findOne!.mockResolvedValue({ + ...baseRoute, + reliabilityScore: 97, + }); + + const factor = await service.getReliabilityRankingFactor( + baseRoute.bridgeName, + baseRoute.sourceChain, + baseRoute.destinationChain, + ); + + expect(factor.reliabilityScore).toBe(97); + expect(factor.penaltyApplied).toBe(false); + expect(factor.adjustedScore).toBe(97); + }); + + it('applies penalty for unreliable bridge', async () => { + metricRepo.findOne!.mockResolvedValue({ + ...baseRoute, + reliabilityScore: 70, + }); + + const factor = await service.getReliabilityRankingFactor( + baseRoute.bridgeName, + baseRoute.sourceChain, + baseRoute.destinationChain, + ); + + expect(factor.penaltyApplied).toBe(true); + expect(factor.adjustedScore).toBeLessThan(70); + }); + + it('skips penalty when ignoreReliability is true', async () => { + metricRepo.findOne!.mockResolvedValue({ + ...baseRoute, + reliabilityScore: 70, + }); + + const factor = await service.getReliabilityRankingFactor( + baseRoute.bridgeName, + baseRoute.sourceChain, + baseRoute.destinationChain, + { ignoreReliability: true }, + ); + + expect(factor.penaltyApplied).toBe(false); + expect(factor.adjustedScore).toBe(70); + }); + + it('returns score 0 when no metric exists for bridge', async () => { + metricRepo.findOne!.mockResolvedValue(null); + + const factor = await service.getReliabilityRankingFactor( + 'UnknownBridge', + 'ethereum', + 'polygon', + ); + + expect(factor.reliabilityScore).toBe(0); + }); + }); + + describe('getBulkReliabilityFactors', () => { + it('returns ranked factors for all bridges on a route', async () => { + metricRepo.find!.mockResolvedValue([ + { bridgeName: 'Stargate', sourceChain: 'ethereum', destinationChain: 'polygon', reliabilityScore: 97 }, + { bridgeName: 'Across', sourceChain: 'ethereum', destinationChain: 'polygon', reliabilityScore: 72 }, + { bridgeName: 'Hop', sourceChain: 'ethereum', destinationChain: 'polygon', reliabilityScore: 88 }, + ]); + + const factors = await service.getBulkReliabilityFactors('ethereum', 'polygon'); + + expect(factors).toHaveLength(3); + + const stargate = factors.find(f => f.bridgeName === 'Stargate')!; + const across = factors.find(f => f.bridgeName === 'Across')!; + + expect(stargate.penaltyApplied).toBe(false); + expect(across.penaltyApplied).toBe(true); + expect(stargate.adjustedScore).toBeGreaterThan(across.adjustedScore); + }); + }); +}); diff --git a/src/reliability-score/bridge-reliability.service.ts b/src/reliability-score/bridge-reliability.service.ts new file mode 100644 index 0000000..12aee82 --- /dev/null +++ b/src/reliability-score/bridge-reliability.service.ts @@ -0,0 +1,312 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, MoreThanOrEqual } from 'typeorm'; +import { BridgeTransactionEvent } from './entities/bridge-transaction-event.entity'; +import { BridgeReliabilityMetric } from './entities/bridge-reliability-metric.entity'; +import { ReliabilityCalculatorService, RawCounts } from './reliability-calculator.service'; +import { + RecordBridgeEventDto, + GetReliabilityDto, + BridgeReliabilityResponseDto, + ReliabilityRankingFactorDto, +} from './dto/reliability.dto'; +import { TransactionOutcome, WindowMode } from './enums/reliability.enum'; +import { + RELIABILITY_CONSTANTS, +} from './constants/reliability.constants'; + +@Injectable() +export class BridgeReliabilityService { + private readonly logger = new Logger(BridgeReliabilityService.name); + + constructor( + @InjectRepository(BridgeTransactionEvent) + private readonly eventRepo: Repository, + + @InjectRepository(BridgeReliabilityMetric) + private readonly metricRepo: Repository, + + private readonly calculator: ReliabilityCalculatorService, + ) {} + + // ─── Record Event ────────────────────────────────────────────────────────── + + async recordEvent(dto: RecordBridgeEventDto): Promise { + const event = this.eventRepo.create({ + bridgeName: dto.bridgeName, + sourceChain: dto.sourceChain.toLowerCase(), + destinationChain: dto.destinationChain.toLowerCase(), + outcome: dto.outcome, + transactionHash: dto.transactionHash ?? null, + failureReason: dto.failureReason ?? null, + durationMs: dto.durationMs ?? 0, + }); + + const saved = await this.eventRepo.save(event); + this.logger.log( + `Recorded ${dto.outcome} event for ${dto.bridgeName} [${dto.sourceChain} → ${dto.destinationChain}]`, + ); + + // Invalidate cached metric so next query recalculates + await this.invalidateCachedMetric(dto.bridgeName, dto.sourceChain, dto.destinationChain); + + return saved; + } + + // ─── Rolling Window Queries ──────────────────────────────────────────────── + + private async getRollingCounts( + bridgeName: string, + sourceChain: string, + destinationChain: string, + windowMode: WindowMode, + windowSize: number, + ): Promise { + const baseWhere = { + bridgeName, + sourceChain: sourceChain.toLowerCase(), + destinationChain: destinationChain.toLowerCase(), + }; + + // Exclude cancelled transactions + const excludedOutcomes = [TransactionOutcome.CANCELLED]; + + if (windowMode === WindowMode.TIME_BASED) { + const since = new Date(); + since.setDate(since.getDate() - windowSize); + + const events = await this.eventRepo.find({ + where: { + ...baseWhere, + createdAt: MoreThanOrEqual(since), + }, + select: ['outcome'], + }); + + return this.aggregateCounts(events.filter(e => !excludedOutcomes.includes(e.outcome))); + } + + // TRANSACTION_COUNT mode: last N non-cancelled events + const events = await this.eventRepo.find({ + where: baseWhere, + order: { createdAt: 'DESC' }, + take: windowSize + 200, // over-fetch to account for cancelled + select: ['outcome'], + }); + + const filtered = events + .filter(e => !excludedOutcomes.includes(e.outcome)) + .slice(0, windowSize); + + return this.aggregateCounts(filtered); + } + + private aggregateCounts(events: Pick[]): RawCounts { + const counts: RawCounts = { + totalAttempts: events.length, + successfulTransfers: 0, + failedTransfers: 0, + timeoutCount: 0, + }; + + for (const event of events) { + if (event.outcome === TransactionOutcome.SUCCESS) counts.successfulTransfers++; + else if (event.outcome === TransactionOutcome.FAILED) counts.failedTransfers++; + else if (event.outcome === TransactionOutcome.TIMEOUT) counts.timeoutCount++; + } + + return counts; + } + + // ─── Compute & Cache Reliability ────────────────────────────────────────── + + async getReliability(dto: GetReliabilityDto): Promise { + const windowMode = dto.windowMode ?? WindowMode.TRANSACTION_COUNT; + const windowSize = + dto.windowSize ?? + (windowMode === WindowMode.TIME_BASED + ? RELIABILITY_CONSTANTS.DEFAULT_WINDOW_DAYS + : RELIABILITY_CONSTANTS.DEFAULT_WINDOW_SIZE); + + const counts = await this.getRollingCounts( + dto.bridgeName, + dto.sourceChain, + dto.destinationChain, + windowMode, + windowSize, + ); + + const reliabilityPercent = this.calculator.computeReliabilityPercent(counts); + const reliabilityScore = this.calculator.computeReliabilityScore(counts); + const tier = this.calculator.computeTier(reliabilityPercent); + const badge = this.calculator.buildBadge(reliabilityPercent, windowSize, windowMode); + + // Upsert cached metric for ranking engine access + const metric = await this.upsertMetric({ + bridgeName: dto.bridgeName, + sourceChain: dto.sourceChain.toLowerCase(), + destinationChain: dto.destinationChain.toLowerCase(), + ...counts, + reliabilityPercent, + reliabilityScore, + reliabilityTier: tier, + windowMode, + windowSize, + }); + + return { + bridgeName: dto.bridgeName, + sourceChain: dto.sourceChain.toLowerCase(), + destinationChain: dto.destinationChain.toLowerCase(), + totalAttempts: counts.totalAttempts, + successfulTransfers: counts.successfulTransfers, + failedTransfers: counts.failedTransfers, + timeoutCount: counts.timeoutCount, + reliabilityPercent, + reliabilityScore, + badge, + lastComputedAt: metric.lastComputedAt, + }; + } + + // ─── Ranking Engine Integration ─────────────────────────────────────────── + + async getReliabilityRankingFactor( + bridgeName: string, + sourceChain: string, + destinationChain: string, + options: { + threshold?: number; + ignoreReliability?: boolean; + } = {}, + ): Promise { + const metric = await this.metricRepo.findOne({ + where: { + bridgeName, + sourceChain: sourceChain.toLowerCase(), + destinationChain: destinationChain.toLowerCase(), + }, + }); + + const reliabilityScore = metric?.reliabilityScore ?? 0; + const threshold = options.threshold ?? RELIABILITY_CONSTANTS.MEDIUM_THRESHOLD; + const penaltyApplied = + !options.ignoreReliability && reliabilityScore < threshold; + + const adjustedScore = options.ignoreReliability + ? reliabilityScore + : reliabilityScore - (penaltyApplied ? RELIABILITY_CONSTANTS.PENALTY_BELOW_THRESHOLD : 0); + + return { + bridgeName, + sourceChain: sourceChain.toLowerCase(), + destinationChain: destinationChain.toLowerCase(), + reliabilityScore, + penaltyApplied, + adjustedScore: Math.max(0, adjustedScore), + }; + } + + /** + * Bulk fetch reliability factors for all bridges on a route. + * Used by the Smart Bridge Ranking engine to sort bridges. + */ + async getBulkReliabilityFactors( + sourceChain: string, + destinationChain: string, + options: { threshold?: number; ignoreReliability?: boolean } = {}, + ): Promise { + const metrics = await this.metricRepo.find({ + where: { + sourceChain: sourceChain.toLowerCase(), + destinationChain: destinationChain.toLowerCase(), + }, + }); + + return metrics.map((m) => { + const threshold = options.threshold ?? RELIABILITY_CONSTANTS.MEDIUM_THRESHOLD; + const penaltyApplied = + !options.ignoreReliability && Number(m.reliabilityScore) < threshold; + return { + bridgeName: m.bridgeName, + sourceChain: m.sourceChain, + destinationChain: m.destinationChain, + reliabilityScore: Number(m.reliabilityScore), + penaltyApplied, + adjustedScore: Math.max( + 0, + Number(m.reliabilityScore) - + (penaltyApplied ? RELIABILITY_CONSTANTS.PENALTY_BELOW_THRESHOLD : 0), + ), + }; + }); + } + + // ─── Admin / Maintenance ────────────────────────────────────────────────── + + async getAllMetrics(): Promise { + return this.metricRepo.find({ order: { reliabilityScore: 'DESC' } }); + } + + // ─── Private Helpers ────────────────────────────────────────────────────── + + private async upsertMetric(data: { + bridgeName: string; + sourceChain: string; + destinationChain: string; + totalAttempts: number; + successfulTransfers: number; + failedTransfers: number; + timeoutCount: number; + reliabilityPercent: number; + reliabilityScore: number; + reliabilityTier: any; + windowMode: WindowMode; + windowSize: number; + }): Promise { + let metric = await this.metricRepo.findOne({ + where: { + bridgeName: data.bridgeName, + sourceChain: data.sourceChain, + destinationChain: data.destinationChain, + }, + }); + + if (!metric) { + metric = this.metricRepo.create({ + bridgeName: data.bridgeName, + sourceChain: data.sourceChain, + destinationChain: data.destinationChain, + }); + } + + Object.assign(metric, { + totalAttempts: data.totalAttempts, + successfulTransfers: data.successfulTransfers, + failedTransfers: data.failedTransfers, + timeoutCount: data.timeoutCount, + reliabilityPercent: data.reliabilityPercent, + reliabilityScore: data.reliabilityScore, + reliabilityTier: data.reliabilityTier, + windowConfig: { mode: data.windowMode, size: data.windowSize }, + }); + + return this.metricRepo.save(metric); + } + + private async invalidateCachedMetric( + bridgeName: string, + sourceChain: string, + destinationChain: string, + ): Promise { + // Simply sets score to stale; actual recompute happens on next getReliability call + await this.metricRepo.update( + { + bridgeName, + sourceChain: sourceChain.toLowerCase(), + destinationChain: destinationChain.toLowerCase(), + }, + { totalAttempts: () => '"totalAttempts"' }, // triggers updatedAt refresh + ); + } +} diff --git a/src/reliability-score/bridge-transaction-event.entity.ts b/src/reliability-score/bridge-transaction-event.entity.ts new file mode 100644 index 0000000..301a529 --- /dev/null +++ b/src/reliability-score/bridge-transaction-event.entity.ts @@ -0,0 +1,40 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + Index, +} from 'typeorm'; +import { TransactionOutcome } from '../enums/reliability.enum'; + +@Entity('bridge_transaction_events') +@Index(['bridgeName', 'sourceChain', 'destinationChain', 'createdAt']) +export class BridgeTransactionEvent { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ length: 100 }) + @Index() + bridgeName: string; + + @Column({ length: 50 }) + sourceChain: string; + + @Column({ length: 50 }) + destinationChain: string; + + @Column({ type: 'enum', enum: TransactionOutcome }) + outcome: TransactionOutcome; + + @Column({ nullable: true, type: 'varchar', length: 255 }) + transactionHash: string | null; + + @Column({ nullable: true, type: 'text' }) + failureReason: string | null; + + @Column({ type: 'int', default: 0, comment: 'ms to settlement or timeout' }) + durationMs: number; + + @CreateDateColumn() + createdAt: Date; +} diff --git a/src/reliability-score/index.ts b/src/reliability-score/index.ts new file mode 100644 index 0000000..7b5855b --- /dev/null +++ b/src/reliability-score/index.ts @@ -0,0 +1,8 @@ +export * from './bridge-reliability.module'; +export * from './bridge-reliability.service'; +export * from './reliability-calculator.service'; +export * from './entities/bridge-transaction-event.entity'; +export * from './entities/bridge-reliability-metric.entity'; +export * from './dto/reliability.dto'; +export * from './enums/reliability.enum'; +export * from './constants/reliability.constants'; diff --git a/src/reliability-score/reliability-calculator.service.spec.ts b/src/reliability-score/reliability-calculator.service.spec.ts new file mode 100644 index 0000000..efd1c93 --- /dev/null +++ b/src/reliability-score/reliability-calculator.service.spec.ts @@ -0,0 +1,261 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ReliabilityCalculatorService, RawCounts } from './reliability-calculator.service'; +import { ReliabilityTier, WindowMode } from './enums/reliability.enum'; +import { RELIABILITY_CONSTANTS } from './constants/reliability.constants'; + +describe('ReliabilityCalculatorService', () => { + let service: ReliabilityCalculatorService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ReliabilityCalculatorService], + }).compile(); + + service = module.get(ReliabilityCalculatorService); + }); + + // ─── computeReliabilityPercent ───────────────────────────────────────────── + + describe('computeReliabilityPercent', () => { + it('returns 0 when attempts are below minimum threshold', () => { + const counts: RawCounts = { + totalAttempts: 3, + successfulTransfers: 3, + failedTransfers: 0, + timeoutCount: 0, + }; + expect(service.computeReliabilityPercent(counts)).toBe(0); + }); + + it('returns 100 when all transfers succeed', () => { + const counts: RawCounts = { + totalAttempts: 100, + successfulTransfers: 100, + failedTransfers: 0, + timeoutCount: 0, + }; + expect(service.computeReliabilityPercent(counts)).toBe(100); + }); + + it('returns 0 when all transfers fail', () => { + const counts: RawCounts = { + totalAttempts: 100, + successfulTransfers: 0, + failedTransfers: 100, + timeoutCount: 0, + }; + expect(service.computeReliabilityPercent(counts)).toBe(0); + }); + + it('computes correct percentage with mixed outcomes', () => { + const counts: RawCounts = { + totalAttempts: 200, + successfulTransfers: 190, + failedTransfers: 7, + timeoutCount: 3, + }; + expect(service.computeReliabilityPercent(counts)).toBe(95); + }); + + it('rounds to 2 decimal places', () => { + const counts: RawCounts = { + totalAttempts: 300, + successfulTransfers: 289, + failedTransfers: 11, + timeoutCount: 0, + }; + const result = service.computeReliabilityPercent(counts); + expect(result).toBe(96.33); + }); + }); + + // ─── computeReliabilityScore ─────────────────────────────────────────────── + + describe('computeReliabilityScore', () => { + it('returns 0 for insufficient data', () => { + const counts: RawCounts = { + totalAttempts: 2, + successfulTransfers: 2, + failedTransfers: 0, + timeoutCount: 0, + }; + expect(service.computeReliabilityScore(counts)).toBe(0); + }); + + it('returns 100 for perfect success with no timeouts', () => { + const counts: RawCounts = { + totalAttempts: 100, + successfulTransfers: 100, + failedTransfers: 0, + timeoutCount: 0, + }; + expect(service.computeReliabilityScore(counts)).toBe(100); + }); + + it('applies timeout penalty to score', () => { + const noTimeout: RawCounts = { + totalAttempts: 100, + successfulTransfers: 95, + failedTransfers: 5, + timeoutCount: 0, + }; + const withTimeout: RawCounts = { + totalAttempts: 100, + successfulTransfers: 95, + failedTransfers: 0, + timeoutCount: 5, + }; + + const scoreWithout = service.computeReliabilityScore(noTimeout); + const scoreWith = service.computeReliabilityScore(withTimeout); + + expect(scoreWith).toBeLessThan(scoreWithout); + }); + + it('never returns score below 0', () => { + const counts: RawCounts = { + totalAttempts: 100, + successfulTransfers: 0, + failedTransfers: 50, + timeoutCount: 50, + }; + expect(service.computeReliabilityScore(counts)).toBeGreaterThanOrEqual(0); + }); + + it('never returns score above 100', () => { + const counts: RawCounts = { + totalAttempts: 1000, + successfulTransfers: 1000, + failedTransfers: 0, + timeoutCount: 0, + }; + expect(service.computeReliabilityScore(counts)).toBeLessThanOrEqual(100); + }); + }); + + // ─── computeTier ────────────────────────────────────────────────────────── + + describe('computeTier', () => { + it('returns HIGH for >= 95%', () => { + expect(service.computeTier(95)).toBe(ReliabilityTier.HIGH); + expect(service.computeTier(99.5)).toBe(ReliabilityTier.HIGH); + expect(service.computeTier(100)).toBe(ReliabilityTier.HIGH); + }); + + it('returns MEDIUM for 85-94%', () => { + expect(service.computeTier(85)).toBe(ReliabilityTier.MEDIUM); + expect(service.computeTier(90)).toBe(ReliabilityTier.MEDIUM); + expect(service.computeTier(94.99)).toBe(ReliabilityTier.MEDIUM); + }); + + it('returns LOW for < 85%', () => { + expect(service.computeTier(84.99)).toBe(ReliabilityTier.LOW); + expect(service.computeTier(50)).toBe(ReliabilityTier.LOW); + expect(service.computeTier(0)).toBe(ReliabilityTier.LOW); + }); + }); + + // ─── buildBadge ─────────────────────────────────────────────────────────── + + describe('buildBadge', () => { + it('builds HIGH badge correctly', () => { + const badge = service.buildBadge(97, 100, WindowMode.TRANSACTION_COUNT); + expect(badge.tier).toBe(ReliabilityTier.HIGH); + expect(badge.label).toBe('High Reliability'); + expect(badge.color).toBe('#22c55e'); + expect(badge.tooltip).toContain('last 100 transactions'); + }); + + it('builds MEDIUM badge with correct color', () => { + const badge = service.buildBadge(90, 7, WindowMode.TIME_BASED); + expect(badge.tier).toBe(ReliabilityTier.MEDIUM); + expect(badge.color).toBe('#f59e0b'); + expect(badge.tooltip).toContain('last 7 days'); + }); + + it('builds LOW badge with correct color', () => { + const badge = service.buildBadge(70, 100, WindowMode.TRANSACTION_COUNT); + expect(badge.tier).toBe(ReliabilityTier.LOW); + expect(badge.color).toBe('#ef4444'); + }); + + it('includes minimum attempts in tooltip', () => { + const badge = service.buildBadge(95, 100, WindowMode.TRANSACTION_COUNT); + expect(badge.tooltip).toContain( + String(RELIABILITY_CONSTANTS.MIN_ATTEMPTS_FOR_SCORE), + ); + }); + }); + + // ─── computeRankingPenalty ──────────────────────────────────────────────── + + describe('computeRankingPenalty', () => { + it('returns 0 penalty for reliable bridges', () => { + expect(service.computeRankingPenalty(90)).toBe(0); + expect(service.computeRankingPenalty(100)).toBe(0); + expect(service.computeRankingPenalty(85)).toBe(0); + }); + + it('returns penalty for unreliable bridges', () => { + expect(service.computeRankingPenalty(80)).toBe( + RELIABILITY_CONSTANTS.PENALTY_BELOW_THRESHOLD, + ); + expect(service.computeRankingPenalty(0)).toBe( + RELIABILITY_CONSTANTS.PENALTY_BELOW_THRESHOLD, + ); + }); + + it('respects custom threshold', () => { + expect(service.computeRankingPenalty(90, 95)).toBe( + RELIABILITY_CONSTANTS.PENALTY_BELOW_THRESHOLD, + ); + expect(service.computeRankingPenalty(90, 80)).toBe(0); + }); + }); + + // ─── applyReliabilityToRankingScore ─────────────────────────────────────── + + describe('applyReliabilityToRankingScore', () => { + it('does not modify score when ignoreReliability is true', () => { + const base = 75; + const result = service.applyReliabilityToRankingScore(base, 60, { + ignoreReliability: true, + }); + expect(result).toBe(base); + }); + + it('integrates reliability into ranking score with default weight', () => { + const base = 80; + const reliability = 95; + const result = service.applyReliabilityToRankingScore(base, reliability); + // base * 0.8 + reliability * 0.2 = 64 + 19 = 83 + expect(result).toBe(83); + }); + + it('applies penalty for unreliable bridges', () => { + const base = 80; + const highReliability = 90; // above threshold + const lowReliability = 70; // below threshold + + const highResult = service.applyReliabilityToRankingScore(base, highReliability); + const lowResult = service.applyReliabilityToRankingScore(base, lowReliability); + + expect(lowResult).toBeLessThan(highResult); + }); + + it('never returns negative ranking score', () => { + const result = service.applyReliabilityToRankingScore(5, 0); + expect(result).toBeGreaterThanOrEqual(0); + }); + + it('respects custom reliability weight', () => { + const base = 80; + const reliability = 100; + // weight 0.5: base * 0.5 + reliability * 0.5 = 40 + 50 = 90 + const result = service.applyReliabilityToRankingScore(base, reliability, { + weight: 0.5, + }); + expect(result).toBe(90); + }); + }); +}); diff --git a/src/reliability-score/reliability-calculator.service.ts b/src/reliability-score/reliability-calculator.service.ts new file mode 100644 index 0000000..a039e07 --- /dev/null +++ b/src/reliability-score/reliability-calculator.service.ts @@ -0,0 +1,134 @@ +import { Injectable } from '@nestjs/common'; +import { RELIABILITY_CONSTANTS, RELIABILITY_BADGE_LABELS } from '../constants/reliability.constants'; +import { ReliabilityTier } from '../enums/reliability.enum'; +import { ReliabilityBadgeDto } from '../dto/reliability.dto'; + +export interface RawCounts { + totalAttempts: number; + successfulTransfers: number; + failedTransfers: number; + timeoutCount: number; +} + +@Injectable() +export class ReliabilityCalculatorService { + /** + * Compute reliability percentage from raw counts. + * Cancelled transactions are already excluded at query level. + */ + computeReliabilityPercent(counts: RawCounts): number { + if (counts.totalAttempts < RELIABILITY_CONSTANTS.MIN_ATTEMPTS_FOR_SCORE) { + return 0; + } + return parseFloat( + ((counts.successfulTransfers / counts.totalAttempts) * 100).toFixed(2), + ); + } + + /** + * Normalize reliability percentage to a 0-100 score. + * Currently 1:1 since percent is already 0-100, but this layer + * allows future weighting (e.g., heavier penalty for timeouts). + */ + computeReliabilityScore(counts: RawCounts): number { + const percent = this.computeReliabilityPercent(counts); + + if (counts.totalAttempts < RELIABILITY_CONSTANTS.MIN_ATTEMPTS_FOR_SCORE) { + return 0; + } + + // Apply extra timeout penalty: each timeout beyond a threshold reduces score + const timeoutRatio = counts.timeoutCount / counts.totalAttempts; + const timeoutPenalty = Math.min(timeoutRatio * 10, 5); // max 5-point penalty + + const rawScore = percent - timeoutPenalty; + return parseFloat( + Math.max(RELIABILITY_CONSTANTS.MIN_SCORE, Math.min(RELIABILITY_CONSTANTS.MAX_SCORE, rawScore)).toFixed(2), + ); + } + + /** + * Determine tier based on reliability percent. + */ + computeTier(reliabilityPercent: number): ReliabilityTier { + if (reliabilityPercent >= RELIABILITY_CONSTANTS.HIGH_THRESHOLD) { + return ReliabilityTier.HIGH; + } + if (reliabilityPercent >= RELIABILITY_CONSTANTS.MEDIUM_THRESHOLD) { + return ReliabilityTier.MEDIUM; + } + return ReliabilityTier.LOW; + } + + /** + * Build badge DTO for UI display. + */ + buildBadge( + reliabilityPercent: number, + windowSize: number, + windowMode: string, + ): ReliabilityBadgeDto { + const tier = this.computeTier(reliabilityPercent); + + const colorMap: Record = { + [ReliabilityTier.HIGH]: '#22c55e', + [ReliabilityTier.MEDIUM]: '#f59e0b', + [ReliabilityTier.LOW]: '#ef4444', + }; + + const windowDesc = + windowMode === 'TIME_BASED' + ? `last ${windowSize} days` + : `last ${windowSize} transactions`; + + return { + tier, + label: RELIABILITY_BADGE_LABELS[tier], + color: colorMap[tier], + tooltip: `Score based on ${windowDesc}. Excludes user-cancelled events. Minimum ${RELIABILITY_CONSTANTS.MIN_ATTEMPTS_FOR_SCORE} attempts required.`, + }; + } + + /** + * Compute ranking penalty for bridges below threshold. + * Used by Smart Bridge Ranking (Issue #5). + */ + computeRankingPenalty( + reliabilityScore: number, + threshold = RELIABILITY_CONSTANTS.MEDIUM_THRESHOLD, + ): number { + if (reliabilityScore < threshold) { + return RELIABILITY_CONSTANTS.PENALTY_BELOW_THRESHOLD; + } + return 0; + } + + /** + * Produce adjusted score for ranking engine. + * Ranking engine calls this to integrate reliability. + */ + applyReliabilityToRankingScore( + baseRankingScore: number, + reliabilityScore: number, + options: { + weight?: number; // 0-1, how much reliability influences ranking + threshold?: number; // penalize below this % + ignoreReliability?: boolean; + } = {}, + ): number { + if (options.ignoreReliability) return baseRankingScore; + + const weight = options.weight ?? 0.2; // 20% weight by default + const penalty = this.computeRankingPenalty( + reliabilityScore, + options.threshold, + ); + + const reliabilityContribution = reliabilityScore * weight; + const baseContribution = baseRankingScore * (1 - weight); + + return parseFloat( + Math.max(0, baseContribution + reliabilityContribution - penalty).toFixed(2), + ); + } +} diff --git a/src/reliability-score/reliability.constants.ts b/src/reliability-score/reliability.constants.ts new file mode 100644 index 0000000..b3822e1 --- /dev/null +++ b/src/reliability-score/reliability.constants.ts @@ -0,0 +1,16 @@ +export const RELIABILITY_CONSTANTS = { + HIGH_THRESHOLD: 95, + MEDIUM_THRESHOLD: 85, + DEFAULT_WINDOW_SIZE: 100, // last 100 transactions + DEFAULT_WINDOW_DAYS: 7, // last 7 days + MIN_ATTEMPTS_FOR_SCORE: 5, // minimum attempts before scoring + PENALTY_BELOW_THRESHOLD: 20, // ranking penalty points + MAX_SCORE: 100, + MIN_SCORE: 0, +} as const; + +export const RELIABILITY_BADGE_LABELS = { + HIGH: 'High Reliability', + MEDIUM: 'Medium Reliability', + LOW: 'Low Reliability', +} as const; diff --git a/src/reliability-score/reliability.dto.ts b/src/reliability-score/reliability.dto.ts new file mode 100644 index 0000000..66836c5 --- /dev/null +++ b/src/reliability-score/reliability.dto.ts @@ -0,0 +1,142 @@ +import { + IsEnum, + IsNotEmpty, + IsNumber, + IsOptional, + IsString, + Max, + Min, +} from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { TransactionOutcome, WindowMode } from '../enums/reliability.enum'; +import { ReliabilityTier } from '../enums/reliability.enum'; + +// ─── Record Event ──────────────────────────────────────────────────────────── +export class RecordBridgeEventDto { + @ApiProperty({ example: 'Stargate' }) + @IsString() + @IsNotEmpty() + bridgeName: string; + + @ApiProperty({ example: 'ethereum' }) + @IsString() + @IsNotEmpty() + sourceChain: string; + + @ApiProperty({ example: 'polygon' }) + @IsString() + @IsNotEmpty() + destinationChain: string; + + @ApiProperty({ enum: TransactionOutcome }) + @IsEnum(TransactionOutcome) + outcome: TransactionOutcome; + + @ApiPropertyOptional({ example: '0xabc123' }) + @IsOptional() + @IsString() + transactionHash?: string; + + @ApiPropertyOptional({ example: 'RPC timeout after 30s' }) + @IsOptional() + @IsString() + failureReason?: string; + + @ApiPropertyOptional({ example: 12000 }) + @IsOptional() + @IsNumber() + @Min(0) + durationMs?: number; +} + +// ─── Query Reliability ──────────────────────────────────────────────────────── +export class GetReliabilityDto { + @ApiProperty({ example: 'Stargate' }) + @IsString() + @IsNotEmpty() + bridgeName: string; + + @ApiProperty({ example: 'ethereum' }) + @IsString() + @IsNotEmpty() + sourceChain: string; + + @ApiProperty({ example: 'polygon' }) + @IsString() + @IsNotEmpty() + destinationChain: string; + + @ApiPropertyOptional({ enum: WindowMode, default: WindowMode.TRANSACTION_COUNT }) + @IsOptional() + @IsEnum(WindowMode) + windowMode?: WindowMode; + + @ApiPropertyOptional({ example: 100 }) + @IsOptional() + @IsNumber() + @Min(1) + @Max(10000) + windowSize?: number; +} + +// ─── Response ───────────────────────────────────────────────────────────────── +export class ReliabilityBadgeDto { + @ApiProperty({ enum: ReliabilityTier }) + tier: ReliabilityTier; + + @ApiProperty({ example: 'High Reliability' }) + label: string; + + @ApiProperty({ example: '#22c55e' }) + color: string; + + @ApiProperty({ + example: 'Score based on last 100 transactions. Excludes user-cancelled events.', + }) + tooltip: string; +} + +export class BridgeReliabilityResponseDto { + @ApiProperty({ example: 'Stargate' }) + bridgeName: string; + + @ApiProperty({ example: 'ethereum' }) + sourceChain: string; + + @ApiProperty({ example: 'polygon' }) + destinationChain: string; + + @ApiProperty({ example: 240 }) + totalAttempts: number; + + @ApiProperty({ example: 235 }) + successfulTransfers: number; + + @ApiProperty({ example: 3 }) + failedTransfers: number; + + @ApiProperty({ example: 2 }) + timeoutCount: number; + + @ApiProperty({ example: 97.92 }) + reliabilityPercent: number; + + @ApiProperty({ example: 97.92 }) + reliabilityScore: number; + + @ApiProperty({ type: ReliabilityBadgeDto }) + badge: ReliabilityBadgeDto; + + @ApiProperty({ example: '2024-01-15T10:30:00.000Z' }) + lastComputedAt: Date; +} + +// ─── Ranking Integration ────────────────────────────────────────────────────── +export class ReliabilityRankingFactorDto { + bridgeName: string; + sourceChain: string; + destinationChain: string; + reliabilityScore: number; + penaltyApplied: boolean; + adjustedScore: number; +} diff --git a/src/reliability-score/reliability.enum.ts b/src/reliability-score/reliability.enum.ts new file mode 100644 index 0000000..e3690c0 --- /dev/null +++ b/src/reliability-score/reliability.enum.ts @@ -0,0 +1,17 @@ +export enum TransactionOutcome { + SUCCESS = 'SUCCESS', + FAILED = 'FAILED', + TIMEOUT = 'TIMEOUT', + CANCELLED = 'CANCELLED', // excluded from reliability calc +} + +export enum ReliabilityTier { + HIGH = 'HIGH', // >= 95% + MEDIUM = 'MEDIUM', // 85-94% + LOW = 'LOW', // < 85% +} + +export enum WindowMode { + TRANSACTION_COUNT = 'TRANSACTION_COUNT', // last N transactions + TIME_BASED = 'TIME_BASED', // last N days +}