diff --git a/Dockerfile b/Dockerfile index 3b46270..75550fc 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,8 +16,8 @@ COPY . . # Generate Prisma client RUN npm run prisma:generate -# Build application (if you have a build script) -# RUN npm run build +# Build application +RUN npm run build:backend # Runtime stage FROM node:22-alpine @@ -37,7 +37,7 @@ RUN npm ci --only=production && \ # Copy built application from builder COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma -COPY --from=builder /app/src ./src +COPY --from=builder /app/apps/backend/dist ./dist # Create non-root user RUN addgroup -g 1001 -S nodejs && \ @@ -55,4 +55,4 @@ HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ # Use dumb-init to handle signals properly ENTRYPOINT ["dumb-init", "--"] -CMD ["node", "src/main.js"] +CMD ["node", "dist/apps/backend/src/main.js"] diff --git a/apps/backend/src/app.module.ts b/apps/backend/src/app.module.ts index 161d550..88bae4d 100644 --- a/apps/backend/src/app.module.ts +++ b/apps/backend/src/app.module.ts @@ -4,9 +4,16 @@ import { DatabaseModule } from '../../../database/database.module'; import { HealthModule } from './modules/health/health.module'; import { NotificationsModule } from './modules/notifications/notifications.module'; import { ReportingModule } from './modules/reporting/reporting.module'; +import { DependencyTrackerModule } from './modules/contracts/dependencies/dependency-tracker.module'; @Module({ - imports: [DatabaseModule, HealthModule, NotificationsModule, ReportingModule], + imports: [ + DatabaseModule, + HealthModule, + NotificationsModule, + ReportingModule, + DependencyTrackerModule, + ], controllers: [AppController], }) export class AppModule {} diff --git a/apps/backend/src/modules/contracts/dependencies/dependency-tracker.controller.ts b/apps/backend/src/modules/contracts/dependencies/dependency-tracker.controller.ts new file mode 100644 index 0000000..b5ed911 --- /dev/null +++ b/apps/backend/src/modules/contracts/dependencies/dependency-tracker.controller.ts @@ -0,0 +1,41 @@ +import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common'; +import { DependencyTrackerService } from './dependency-tracker.service'; +import { + AddDependencyDto, + ContractNode, + DependencyGraph, + RegisterContractDto, +} from './interfaces/dependency-tracker.interface'; + +/** + * REST API for the Contract Dependency Tracker. + * + * POST /contracts/dependencies/register — register a contract + * POST /contracts/dependencies — add a dependency edge + * GET /contracts/dependencies — list all registered contracts + * GET /contracts/dependencies/graph/:address — full dependency graph for a contract + */ +@Controller('contracts/dependencies') +export class DependencyTrackerController { + constructor(private readonly trackerService: DependencyTrackerService) {} + + @Post('register') + register(@Body() dto: RegisterContractDto): ContractNode { + return this.trackerService.registerContract(dto); + } + + @Post() + addDependency(@Body() dto: AddDependencyDto): ContractNode { + return this.trackerService.addDependency(dto); + } + + @Get() + getAll(): ContractNode[] { + return this.trackerService.getAll(); + } + + @Get('graph/:address') + getGraph(@Param('address') address: string, @Query('chain') chain: string): DependencyGraph { + return this.trackerService.getGraph(address, chain); + } +} diff --git a/apps/backend/src/modules/contracts/dependencies/dependency-tracker.module.ts b/apps/backend/src/modules/contracts/dependencies/dependency-tracker.module.ts new file mode 100644 index 0000000..f73be97 --- /dev/null +++ b/apps/backend/src/modules/contracts/dependencies/dependency-tracker.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { DependencyTrackerController } from './dependency-tracker.controller'; +import { DependencyTrackerService } from './dependency-tracker.service'; + +@Module({ + controllers: [DependencyTrackerController], + providers: [DependencyTrackerService], + exports: [DependencyTrackerService], +}) +export class DependencyTrackerModule {} diff --git a/apps/backend/src/modules/contracts/dependencies/dependency-tracker.service.spec.ts b/apps/backend/src/modules/contracts/dependencies/dependency-tracker.service.spec.ts new file mode 100644 index 0000000..a595a0e --- /dev/null +++ b/apps/backend/src/modules/contracts/dependencies/dependency-tracker.service.spec.ts @@ -0,0 +1,185 @@ +import 'reflect-metadata'; +import { Test, TestingModule } from '@nestjs/testing'; +import { ConflictException, NotFoundException } from '@nestjs/common'; +import { DependencyTrackerService } from './dependency-tracker.service'; +import { RegisterContractDto, AddDependencyDto } from './interfaces/dependency-tracker.interface'; + +const A: RegisterContractDto = { address: '0xAAAA', chain: 'ethereum', risk: 'low' }; +const B: RegisterContractDto = { address: '0xBBBB', chain: 'ethereum', risk: 'high' }; +const C: RegisterContractDto = { address: '0xCCCC', chain: 'ethereum', risk: 'none' }; + +describe('DependencyTrackerService', () => { + let service: DependencyTrackerService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [DependencyTrackerService], + }).compile(); + + service = module.get(DependencyTrackerService); + }); + + // --------------------------------------------------------------------------- + // registerContract + // --------------------------------------------------------------------------- + describe('registerContract', () => { + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + it('registers a contract and returns the node', () => { + const node = service.registerContract(A); + expect(node.address).toBe(A.address); + expect(node.chain).toBe(A.chain); + expect(node.risk).toBe('low'); + expect(node.dependencies).toHaveLength(0); + }); + + it('defaults risk to "none" when not provided', () => { + const node = service.registerContract({ address: '0xDDDD', chain: 'ethereum' }); + expect(node.risk).toBe('none'); + }); + + it('stores optional label', () => { + const node = service.registerContract({ ...A, label: 'Router' }); + expect(node.label).toBe('Router'); + }); + + it('throws ConflictException on duplicate registration', () => { + service.registerContract(A); + expect(() => service.registerContract(A)).toThrow(ConflictException); + }); + + it('treats address as case-insensitive for deduplication', () => { + service.registerContract(A); + expect(() => service.registerContract({ ...A, address: A.address.toLowerCase() })).toThrow( + ConflictException, + ); + }); + }); + + // --------------------------------------------------------------------------- + // addDependency + // --------------------------------------------------------------------------- + describe('addDependency', () => { + beforeEach(() => { + service.registerContract(A); + service.registerContract(B); + }); + + it('adds a dependency edge and returns the updated source node', () => { + const edge: AddDependencyDto = { from: A.address, to: B.address, chain: 'ethereum' }; + const node = service.addDependency(edge); + expect(node.dependencies).toHaveLength(1); + expect(node.dependencies[0].address).toBe(B.address); + }); + + it('throws NotFoundException when source contract is not registered', () => { + expect(() => + service.addDependency({ from: '0xUnknown', to: B.address, chain: 'ethereum' }), + ).toThrow(NotFoundException); + }); + + it('throws ConflictException on duplicate dependency edge', () => { + const edge: AddDependencyDto = { from: A.address, to: B.address, chain: 'ethereum' }; + service.addDependency(edge); + expect(() => service.addDependency(edge)).toThrow(ConflictException); + }); + + it('records the risk and label on the dependency', () => { + const edge: AddDependencyDto = { + from: A.address, + to: B.address, + chain: 'ethereum', + risk: 'critical', + label: 'Vault', + }; + const node = service.addDependency(edge); + expect(node.dependencies[0].risk).toBe('critical'); + expect(node.dependencies[0].label).toBe('Vault'); + }); + }); + + // --------------------------------------------------------------------------- + // getGraph + // --------------------------------------------------------------------------- + describe('getGraph', () => { + beforeEach(() => { + service.registerContract(A); // risk: low + service.registerContract(B); // risk: high + service.registerContract(C); // risk: none + // A -> B (edge risk: medium) + service.addDependency({ from: A.address, to: B.address, chain: 'ethereum', risk: 'medium' }); + // B -> C + service.addDependency({ from: B.address, to: C.address, chain: 'ethereum', risk: 'none' }); + }); + + it('throws NotFoundException for an unregistered contract', () => { + expect(() => service.getGraph('0xUnknown', 'ethereum')).toThrow(NotFoundException); + }); + + it('returns a graph with root set to the requested address', () => { + const graph = service.getGraph(A.address, 'ethereum'); + expect(graph.root).toBe(A.address); + }); + + it('includes all transitively reachable nodes', () => { + const graph = service.getGraph(A.address, 'ethereum'); + const addresses = graph.nodes.map(n => n.address); + expect(addresses).toContain(A.address); + expect(addresses).toContain(B.address); + expect(addresses).toContain(C.address); + }); + + it('does not include nodes outside the reachable subgraph', () => { + // Register an isolated contract + service.registerContract({ address: '0xEEEE', chain: 'ethereum' }); + const graph = service.getGraph(A.address, 'ethereum'); + const addresses = graph.nodes.map(n => n.address); + expect(addresses).not.toContain('0xEEEE'); + }); + + it('propagates the highest risk across the graph', () => { + // A (low), A->B edge (medium), B node (high) => propagated should be 'high' + const graph = service.getGraph(A.address, 'ethereum'); + expect(graph.propagatedRisk).toBe('high'); + }); + + it('handles a graph with a single node', () => { + service.registerContract({ address: '0xSolo', chain: 'ethereum', risk: 'medium' }); + const graph = service.getGraph('0xSolo', 'ethereum'); + expect(graph.nodes).toHaveLength(1); + expect(graph.propagatedRisk).toBe('medium'); + }); + + it('does not visit the same node twice (cycle-safe)', () => { + // Create a cycle: A -> B -> A is not possible via addDependency to itself, + // but we can add D -> A to form D -> A -> B -> C and verify no infinite loop + service.registerContract({ address: '0xDDDD', chain: 'ethereum' }); + service.addDependency({ from: '0xDDDD', to: A.address, chain: 'ethereum' }); + const graph = service.getGraph('0xDDDD', 'ethereum'); + const addresses = graph.nodes.map(n => n.address); + // No duplicates + expect(new Set(addresses).size).toBe(addresses.length); + }); + }); + + // --------------------------------------------------------------------------- + // getAll + // --------------------------------------------------------------------------- + describe('getAll', () => { + it('returns empty array when no contracts are registered', () => { + expect(service.getAll()).toEqual([]); + }); + + it('returns all registered contracts', () => { + service.registerContract(A); + service.registerContract(B); + const all = service.getAll(); + expect(all).toHaveLength(2); + const addresses = all.map(n => n.address); + expect(addresses).toContain(A.address); + expect(addresses).toContain(B.address); + }); + }); +}); diff --git a/apps/backend/src/modules/contracts/dependencies/dependency-tracker.service.ts b/apps/backend/src/modules/contracts/dependencies/dependency-tracker.service.ts new file mode 100644 index 0000000..f3f25ac --- /dev/null +++ b/apps/backend/src/modules/contracts/dependencies/dependency-tracker.service.ts @@ -0,0 +1,127 @@ +import { Injectable, NotFoundException, ConflictException } from '@nestjs/common'; +import { + AddDependencyDto, + ContractNode, + DependencyGraph, + RegisterContractDto, + RiskLevel, +} from './interfaces/dependency-tracker.interface'; + +const RISK_ORDER: RiskLevel[] = ['none', 'low', 'medium', 'high', 'critical']; + +function maxRisk(a: RiskLevel, b: RiskLevel): RiskLevel { + return RISK_ORDER.indexOf(a) >= RISK_ORDER.indexOf(b) ? a : b; +} + +/** + * Tracks dependency relationships between monitored smart contracts and + * propagates risk levels through the dependency graph. + * + * All state is held in memory; swap the private maps for a database + * repository when persistence is needed. + */ +@Injectable() +export class DependencyTrackerService { + private readonly contracts = new Map(); + + // ------------------------------------------------------------------------- + // Registration + // ------------------------------------------------------------------------- + + registerContract(dto: RegisterContractDto): ContractNode { + const key = this.key(dto.address, dto.chain); + if (this.contracts.has(key)) { + throw new ConflictException( + `Contract ${dto.address} on chain ${dto.chain} is already registered`, + ); + } + const node: ContractNode = { + address: dto.address, + chain: dto.chain, + label: dto.label, + risk: dto.risk ?? 'none', + dependencies: [], + }; + this.contracts.set(key, node); + return node; + } + + // ------------------------------------------------------------------------- + // Dependency management + // ------------------------------------------------------------------------- + + addDependency(dto: AddDependencyDto): ContractNode { + const fromKey = this.key(dto.from, dto.chain); + const source = this.contracts.get(fromKey); + if (!source) { + throw new NotFoundException(`Contract ${dto.from} on chain ${dto.chain} is not registered`); + } + + const alreadyExists = source.dependencies.some(d => d.address === dto.to); + if (alreadyExists) { + throw new ConflictException(`Dependency from ${dto.from} to ${dto.to} already exists`); + } + + source.dependencies.push({ + address: dto.to, + chain: dto.chain, + label: dto.label, + risk: dto.risk ?? 'none', + }); + + return source; + } + + // ------------------------------------------------------------------------- + // Graph query + // ------------------------------------------------------------------------- + + getGraph(address: string, chain: string): DependencyGraph { + const rootKey = this.key(address, chain); + if (!this.contracts.has(rootKey)) { + throw new NotFoundException(`Contract ${address} on chain ${chain} is not registered`); + } + + const visited = new Set(); + const nodes: ContractNode[] = []; + this.traverse(address, chain, visited, nodes); + + const propagatedRisk = nodes.reduce((acc, node) => { + const nodeMax = node.dependencies.reduce((a, d) => maxRisk(a, d.risk), node.risk); + return maxRisk(acc, nodeMax); + }, 'none'); + + return { root: address, nodes, propagatedRisk }; + } + + getAll(): ContractNode[] { + return Array.from(this.contracts.values()); + } + + // ------------------------------------------------------------------------- + // Internals + // ------------------------------------------------------------------------- + + private traverse( + address: string, + chain: string, + visited: Set, + nodes: ContractNode[], + ): void { + const key = this.key(address, chain); + if (visited.has(key)) return; + visited.add(key); + + const node = this.contracts.get(key); + if (!node) return; + nodes.push(node); + + for (const dep of node.dependencies) { + this.traverse(dep.address, dep.chain ?? chain, visited, nodes); + } + } + + private key(address: string, chain: string): string { + return `${chain}:${address.toLowerCase()}`; + } +} diff --git a/apps/backend/src/modules/contracts/dependencies/interfaces/dependency-tracker.interface.ts b/apps/backend/src/modules/contracts/dependencies/interfaces/dependency-tracker.interface.ts new file mode 100644 index 0000000..c6d860e --- /dev/null +++ b/apps/backend/src/modules/contracts/dependencies/interfaces/dependency-tracker.interface.ts @@ -0,0 +1,48 @@ +export type RiskLevel = 'none' | 'low' | 'medium' | 'high' | 'critical'; + +export interface ContractDependency { + /** Address of the upstream contract this contract depends on */ + address: string; + /** Human-readable label (e.g. "UniswapV3Router") */ + label?: string; + /** Chain/network identifier */ + chain: string; + /** Propagated risk level coming from this dependency */ + risk: RiskLevel; +} + +export interface ContractNode { + address: string; + label?: string; + chain: string; + /** Intrinsic risk assigned to this contract */ + risk: RiskLevel; + /** Direct dependencies of this contract */ + dependencies: ContractDependency[]; +} + +export interface DependencyGraph { + /** The root contract whose graph was requested */ + root: string; + /** All nodes reachable from the root (including the root itself) */ + nodes: ContractNode[]; + /** Highest risk level found anywhere in the graph */ + propagatedRisk: RiskLevel; +} + +export interface AddDependencyDto { + /** Source contract (dependent) */ + from: string; + /** Target contract (dependency) */ + to: string; + chain: string; + label?: string; + risk?: RiskLevel; +} + +export interface RegisterContractDto { + address: string; + chain: string; + label?: string; + risk?: RiskLevel; +} diff --git a/tsconfig.json b/tsconfig.json index 30bf81d..66fd165 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -14,7 +14,7 @@ "outDir": "./dist", "rootDir": "./", "baseUrl": "./", - "types": ["node"], + "types": ["node", "jest"], "ignoreDeprecations": "6.0", "paths": { "@common/*": ["apps/backend/src/common/*"], @@ -24,4 +24,4 @@ }, "include": ["apps/backend/**/*", "libs/**/*", "database/**/*"], "exclude": ["node_modules", "dist", "apps/dashboard"] -} \ No newline at end of file +}