From a72852bf3377b50261184f0ecdd306bc2f687254 Mon Sep 17 00:00:00 2001 From: rabsqueen Date: Wed, 22 Jul 2026 18:29:21 +0100 Subject: [PATCH 1/2] feat(monitoring): implement multi-signature monitoring and alert service (#145) --- .../multisig/multisig-monitoring.module.ts | 10 ++ .../multisig-monitoring.service.spec.ts | 67 +++++++++ .../multisig/multisig-monitoring.service.ts | 137 ++++++++++++++++++ .../multisig/types/multisig.types.ts | 40 +++++ 4 files changed, 254 insertions(+) create mode 100644 src/modules/monitoring/multisig/multisig-monitoring.module.ts create mode 100644 src/modules/monitoring/multisig/multisig-monitoring.service.spec.ts create mode 100644 src/modules/monitoring/multisig/multisig-monitoring.service.ts create mode 100644 src/modules/monitoring/multisig/types/multisig.types.ts diff --git a/src/modules/monitoring/multisig/multisig-monitoring.module.ts b/src/modules/monitoring/multisig/multisig-monitoring.module.ts new file mode 100644 index 0000000..7cc6675 --- /dev/null +++ b/src/modules/monitoring/multisig/multisig-monitoring.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { EventEmitterModule } from '@nestjs/event-emitter'; +import { MultisigMonitoringService } from './multisig-monitoring.service'; + +@Module({ + imports: [EventEmitterModule.forRoot()], + providers: [MultisigMonitoringService], + exports: [MultisigMonitoringService], +}) +export class MultisigMonitoringModule {} diff --git a/src/modules/monitoring/multisig/multisig-monitoring.service.spec.ts b/src/modules/monitoring/multisig/multisig-monitoring.service.spec.ts new file mode 100644 index 0000000..2ed7015 --- /dev/null +++ b/src/modules/monitoring/multisig/multisig-monitoring.service.spec.ts @@ -0,0 +1,67 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { MultisigMonitoringService } from './multisig-monitoring.service'; + +describe('MultisigMonitoringService', () => { + let service: MultisigMonitoringService; + let eventEmitter: EventEmitter2; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + MultisigMonitoringService, + { + provide: EventEmitter2, + useValue: { + emit: jest.fn(), + }, + }, + ], + }).compile(); + + service = module.get(MultisigMonitoringService); + eventEmitter = module.get(EventEmitter2); + }); + + it('should register and track multisig wallet configuration', () => { + service.registerWallet({ + address: '0x1234', + network: 'ethereum', + threshold: 2, + signers: ['0xA', '0xB'], + }); + + const config = service.getWalletConfig('0x1234'); + expect(config).toBeDefined(); + expect(config?.threshold).toBe(2); + }); + + it('should detect threshold changes and trigger a CRITICAL alert', async () => { + service.registerWallet({ + address: '0x1234', + network: 'ethereum', + threshold: 2, + signers: ['0xA', '0xB'], + }); + + await service.processActivity({ + multisigAddress: '0x1234', + network: 'ethereum', + type: 'THRESHOLD_CHANGED', + txHash: '0xabc', + oldThreshold: 2, + newThreshold: 1, + }); + + expect(eventEmitter.emit).toHaveBeenCalledWith( + 'multisig.alert', + expect.objectContaining({ + severity: 'CRITICAL', + title: '⚠️ Multisig Threshold Changed', + }), + ); + + const updatedConfig = service.getWalletConfig('0x1234'); + expect(updatedConfig?.threshold).toBe(1); + }); +}); diff --git a/src/modules/monitoring/multisig/multisig-monitoring.service.ts b/src/modules/monitoring/multisig/multisig-monitoring.service.ts new file mode 100644 index 0000000..1b43de5 --- /dev/null +++ b/src/modules/monitoring/multisig/multisig-monitoring.service.ts @@ -0,0 +1,137 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { MultisigAccountConfig, MultisigEvent, MultisigAlert } from './types/multisig.types'; + +@Injectable() +export class MultisigMonitoringService { + private readonly logger = new Logger(MultisigMonitoringService.name); + + // In-memory state tracking for threshold and signers + private trackedWallets: Map = new Map(); + + constructor(private readonly eventEmitter: EventEmitter2) {} + + /** + * Register a multisig wallet for monitoring + */ + registerWallet(config: MultisigAccountConfig): void { + this.trackedWallets.set(config.address.toLowerCase(), { ...config }); + this.logger.log( + `[MultisigMonitor] Registered wallet ${config.address} (Threshold: ${config.threshold}, Signers: ${config.signers.length})`, + ); + } + + /** + * Process raw multisig activity event from blockchain listener + */ + async processActivity(event: Omit): Promise { + const fullEvent: MultisigEvent = { + ...event, + id: `evt_${Date.now()}_${Math.random().toString(36).substr(2, 5)}`, + timestamp: new Date(), + }; + + const walletKey = fullEvent.multisigAddress.toLowerCase(); + const currentState = this.trackedWallets.get(walletKey); + + this.logger.log( + `[MultisigMonitor] Event detected on ${fullEvent.multisigAddress}: ${fullEvent.type}`, + ); + + // Track state modifications + if (currentState) { + this.updateWalletState(currentState, fullEvent); + } + + // Evaluate security alerts + const alert = this.detectSecurityAlert(fullEvent, currentState); + if (alert) { + this.dispatchAlert(alert); + } + + // Emit event for downstream consumers + this.eventEmitter.emit('multisig.activity', fullEvent); + } + + private updateWalletState(state: MultisigAccountConfig, event: MultisigEvent): void { + switch (event.type) { + case 'THRESHOLD_CHANGED': + if (event.newThreshold !== undefined) { + state.threshold = event.newThreshold; + } + break; + case 'SIGNER_ADDED': + if (event.addedSigner && !state.signers.includes(event.addedSigner)) { + state.signers.push(event.addedSigner); + } + break; + case 'SIGNER_REMOVED': + if (event.removedSigner) { + state.signers = state.signers.filter(s => s !== event.removedSigner); + } + break; + } + } + + private detectSecurityAlert( + event: MultisigEvent, + state?: MultisigAccountConfig, + ): MultisigAlert | null { + let severity: 'INFO' | 'WARNING' | 'CRITICAL' | null = null; + let title = ''; + let message = ''; + + switch (event.type) { + case 'THRESHOLD_CHANGED': + severity = 'CRITICAL'; + title = '⚠️ Multisig Threshold Changed'; + message = `Threshold updated from ${event.oldThreshold ?? state?.threshold} to ${event.newThreshold} on ${event.multisigAddress} (Tx: ${event.txHash})`; + break; + + case 'SIGNER_ADDED': + severity = 'WARNING'; + title = '👤 New Signer Added'; + message = `Signer ${event.addedSigner} was added to multisig ${event.multisigAddress} (Tx: ${event.txHash})`; + break; + + case 'SIGNER_REMOVED': + severity = 'WARNING'; + title = '🗑️ Signer Removed'; + message = `Signer ${event.removedSigner} was removed from multisig ${event.multisigAddress} (Tx: ${event.txHash})`; + break; + + case 'APPROVAL_SUBMITTED': + severity = 'INFO'; + title = '✍️ Approval Submitted'; + message = `Signer ${event.signer} approved tx ${event.txHash} (${event.currentApprovals}/${event.requiredApprovals} approvals)`; + break; + + case 'EXECUTION_FAILED': + severity = 'CRITICAL'; + title = '🚨 Transaction Execution Failed'; + message = `Multisig transaction execution failed on ${event.multisigAddress} (Tx: ${event.txHash})`; + break; + } + + if (!severity) return null; + + return { + id: `alt_${Date.now()}`, + severity, + multisigAddress: event.multisigAddress, + title, + message, + event, + timestamp: new Date(), + }; + } + + private dispatchAlert(alert: MultisigAlert): void { + this.logger.warn(`[ALERT][${alert.severity}] ${alert.title}: ${alert.message}`); + this.eventEmitter.emit('multisig.alert', alert); + } + + getWalletConfig(address: string): MultisigAccountConfig | undefined { + return this.trackedWallets.get(address.toLowerCase()); + } +} diff --git a/src/modules/monitoring/multisig/types/multisig.types.ts b/src/modules/monitoring/multisig/types/multisig.types.ts new file mode 100644 index 0000000..af4c37c --- /dev/null +++ b/src/modules/monitoring/multisig/types/multisig.types.ts @@ -0,0 +1,40 @@ +export type MultisigEventType = + | 'SIGNER_ADDED' + | 'SIGNER_REMOVED' + | 'THRESHOLD_CHANGED' + | 'APPROVAL_SUBMITTED' + | 'EXECUTION_SUCCESS' + | 'EXECUTION_FAILED'; + +export interface MultisigAccountConfig { + address: string; + network: string; + threshold: number; + signers: string[]; +} + +export interface MultisigEvent { + id: string; + multisigAddress: string; + network: string; + type: MultisigEventType; + txHash: string; + signer?: string; + newThreshold?: number; + oldThreshold?: number; + addedSigner?: string; + removedSigner?: string; + currentApprovals?: number; + requiredApprovals?: number; + timestamp: Date; +} + +export interface MultisigAlert { + id: string; + severity: 'INFO' | 'WARNING' | 'CRITICAL'; + multisigAddress: string; + title: string; + message: string; + event: MultisigEvent; + timestamp: Date; +} From 6adcd9664d1353dcb9832ada4430f7f5a70c7827 Mon Sep 17 00:00:00 2001 From: rabsqueen Date: Wed, 22 Jul 2026 18:42:23 +0100 Subject: [PATCH 2/2] ci: fix test workflow --- package-lock.json | 33 +++++++++++++++++++++++++++++++++ package.json | 1 + 2 files changed, 34 insertions(+) diff --git a/package-lock.json b/package-lock.json index bd77e40..0d37d74 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "@nestjs/common": "^11.1.27", "@nestjs/core": "^11.1.27", + "@nestjs/event-emitter": "^3.1.0", "@nestjs/jwt": "^11.0.2", "@nestjs/passport": "^11.0.5", "@nestjs/platform-express": "^11.1.28", @@ -2383,6 +2384,19 @@ } } }, + "node_modules/@nestjs/event-emitter": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@nestjs/event-emitter/-/event-emitter-3.1.0.tgz", + "integrity": "sha512-DOY/4XBGyIjYyOJKkO6jl1kzFE0ZfX0wV+M2HR5NWymPT9Z0zdCEcZGxTXXkoMRwPtglnvCGJALSjOpXPIcM3g==", + "license": "MIT", + "dependencies": { + "eventemitter2": "6.4.9" + }, + "peerDependencies": { + "@nestjs/common": "^10.0.0 || ^11.0.0", + "@nestjs/core": "^10.0.0 || ^11.0.0" + } + }, "node_modules/@nestjs/jwt": { "version": "11.0.2", "resolved": "https://registry.npmjs.org/@nestjs/jwt/-/jwt-11.0.2.tgz", @@ -6535,6 +6549,12 @@ "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", "license": "MIT" }, + "node_modules/eventemitter2": { + "version": "6.4.9", + "resolved": "https://registry.npmjs.org/eventemitter2/-/eventemitter2-6.4.9.tgz", + "integrity": "sha512-JEPTiaOt9f04oa6NOkc4aH+nVp5I3wEjpHbIPqfgCdD5v5bUzy7xQqwcVO2aDQgOWhI28da57HksMrzK9HlRxg==", + "license": "MIT" + }, "node_modules/eventemitter3": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", @@ -14958,6 +14978,14 @@ "uid": "2.0.2" } }, + "@nestjs/event-emitter": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@nestjs/event-emitter/-/event-emitter-3.1.0.tgz", + "integrity": "sha512-DOY/4XBGyIjYyOJKkO6jl1kzFE0ZfX0wV+M2HR5NWymPT9Z0zdCEcZGxTXXkoMRwPtglnvCGJALSjOpXPIcM3g==", + "requires": { + "eventemitter2": "6.4.9" + } + }, "@nestjs/jwt": { "version": "11.0.2", "resolved": "https://registry.npmjs.org/@nestjs/jwt/-/jwt-11.0.2.tgz", @@ -17662,6 +17690,11 @@ } } }, + "eventemitter2": { + "version": "6.4.9", + "resolved": "https://registry.npmjs.org/eventemitter2/-/eventemitter2-6.4.9.tgz", + "integrity": "sha512-JEPTiaOt9f04oa6NOkc4aH+nVp5I3wEjpHbIPqfgCdD5v5bUzy7xQqwcVO2aDQgOWhI28da57HksMrzK9HlRxg==" + }, "eventemitter3": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", diff --git a/package.json b/package.json index 780f1d1..6ff6a6d 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,7 @@ "dependencies": { "@nestjs/common": "^11.1.27", "@nestjs/core": "^11.1.27", + "@nestjs/event-emitter": "^3.1.0", "@nestjs/jwt": "^11.0.2", "@nestjs/passport": "^11.0.5", "@nestjs/platform-express": "^11.1.28",