diff --git a/package-lock.json b/package-lock.json index 7abd8fe..07d0ac9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,10 +12,12 @@ "@nestjs/common": "^10.4.1", "@nestjs/config": "^3.2.3", "@nestjs/core": "^10.4.1", + "@nestjs/event-emitter": "^2.0.4", "@nestjs/microservices": "^10.4.1", "@nestjs/passport": "^10.0.0", "@nestjs/platform-express": "^10.3.7", "@nestjs/platform-socket.io": "^10.3.7", + "@nestjs/platform-ws": "^10.4.4", "@nestjs/schedule": "^3.0.0", "@nestjs/swagger": "^7.3.1", "@nestjs/typeorm": "^10.0.2", @@ -39,6 +41,7 @@ "@types/node": "18.15.11", "@types/secp256k1": "^4.0.6", "@types/supertest": "^2.0.11", + "@types/ws": "^8.5.12", "@typescript-eslint/eslint-plugin": "^5.0.0", "@typescript-eslint/parser": "^5.0.0", "bip32": "^2.0.0", @@ -1897,6 +1900,25 @@ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz", "integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==" }, + "node_modules/@nestjs/event-emitter": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@nestjs/event-emitter/-/event-emitter-2.1.1.tgz", + "integrity": "sha512-6L6fBOZTyfFlL7Ih/JDdqlCzZeCW0RjCX28wnzGyg/ncv5F/EOeT1dfopQr1loBRQ3LTgu8OWM7n4zLN4xigsg==", + "license": "MIT", + "dependencies": { + "eventemitter2": "6.4.9" + }, + "peerDependencies": { + "@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0", + "@nestjs/core": "^8.0.0 || ^9.0.0 || ^10.0.0" + } + }, + "node_modules/@nestjs/event-emitter/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/@nestjs/mapped-types": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/@nestjs/mapped-types/-/mapped-types-2.0.5.tgz", @@ -2040,6 +2062,52 @@ "integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==", "license": "0BSD" }, + "node_modules/@nestjs/platform-ws": { + "version": "10.4.11", + "resolved": "https://registry.npmjs.org/@nestjs/platform-ws/-/platform-ws-10.4.11.tgz", + "integrity": "sha512-Plm/69sKls1UcM2x0uGUVbqaGpfpQJiUBItE2kziCeiJm7VtpcXPETzZAvoVzLUdhUdOZzeo6EgGotncuNTEdQ==", + "license": "MIT", + "dependencies": { + "tslib": "2.7.0", + "ws": "8.18.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nest" + }, + "peerDependencies": { + "@nestjs/common": "^10.0.0", + "@nestjs/websockets": "^10.0.0", + "rxjs": "^7.1.0" + } + }, + "node_modules/@nestjs/platform-ws/node_modules/tslib": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz", + "integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==", + "license": "0BSD" + }, + "node_modules/@nestjs/platform-ws/node_modules/ws": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", + "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/@nestjs/schedule": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/@nestjs/schedule/-/schedule-3.0.4.tgz", @@ -2889,6 +2957,16 @@ "integrity": "sha512-FCTsikRozryfayPuiI46QzH3fnrOoctTjvOYZkho9BTFLCOZ2rgZJHMOVgCOfttjPJcgOx52EpkY0CMfy87MIw==", "devOptional": true }, + "node_modules/@types/ws": { + "version": "8.5.13", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.13.tgz", + "integrity": "sha512-osM/gWBTPKgHV8XkTunnegTRIsvF6owmf5w+JtAfOw472dptdm0dlGv4xCt6GwQRcC2XVOvvRE/0bAoQcL2QkA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/yargs": { "version": "17.0.32", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.32.tgz", diff --git a/package.json b/package.json index fe7498d..332c2aa 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "@nestjs/common": "^10.4.1", "@nestjs/config": "^3.2.3", "@nestjs/core": "^10.4.1", + "@nestjs/event-emitter": "^2.0.4", "@nestjs/microservices": "^10.4.1", "@nestjs/passport": "^10.0.0", "@nestjs/platform-express": "^10.3.7", @@ -39,6 +40,7 @@ "@nestjs/swagger": "^7.3.1", "@nestjs/typeorm": "^10.0.2", "@nestjs/websockets": "^10.3.7", + "@nestjs/platform-ws": "^10.4.4", "axios": "^1.7.2", "currency.js": "^2.0.4", "js-yaml": "^4.1.0", @@ -58,6 +60,7 @@ "@types/node": "18.15.11", "@types/secp256k1": "^4.0.6", "@types/supertest": "^2.0.11", + "@types/ws": "^8.5.12", "@typescript-eslint/eslint-plugin": "^5.0.0", "@typescript-eslint/parser": "^5.0.0", "bip32": "^2.0.0", diff --git a/src/app.module.ts b/src/app.module.ts index 623fc86..74f21e1 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -9,10 +9,12 @@ import { SilentBlocksModule } from '@/silent-blocks/silent-blocks.module'; import { OperationStateModule } from '@/operation-state/operation-state.module'; import { ScheduleModule } from '@nestjs/schedule'; import { BlockProviderModule } from '@/block-data-providers/block-provider.module'; +import { EventEmitterModule } from '@nestjs/event-emitter'; @Module({ imports: [ ScheduleModule.forRoot(), + EventEmitterModule.forRoot(), ConfigModule.forRoot({ ignoreEnvFile: true, load: [configuration], diff --git a/src/block-data-providers/base-block-data-provider.abstract.ts b/src/block-data-providers/base-block-data-provider.abstract.ts index 68498cc..c563644 100644 --- a/src/block-data-providers/base-block-data-provider.abstract.ts +++ b/src/block-data-providers/base-block-data-provider.abstract.ts @@ -1,5 +1,6 @@ import { OperationStateService } from '@/operation-state/operation-state.service'; import { Logger } from '@nestjs/common'; +import { EventEmitter2 } from '@nestjs/event-emitter'; import { IndexerService, TransactionInput, @@ -12,6 +13,7 @@ import { EntityManager } from 'typeorm'; import { OperationState } from '@/operation-state/operation-state.entity'; export abstract class BaseBlockDataProvider { + protected readonly eventEmitter: EventEmitter2 = new EventEmitter2(); protected abstract readonly logger: Logger; protected abstract readonly operationStateKey: string; diff --git a/src/block-data-providers/bitcoin-core/provider.spec.ts b/src/block-data-providers/bitcoin-core/provider.spec.ts index 6275e79..7e8df13 100644 --- a/src/block-data-providers/bitcoin-core/provider.spec.ts +++ b/src/block-data-providers/bitcoin-core/provider.spec.ts @@ -14,6 +14,7 @@ import { import { Test, TestingModule } from '@nestjs/testing'; import { BlockStateService } from '@/block-state/block-state.service'; import { DbTransactionService } from '@/db-transaction/db-transaction.service'; +import { EventEmitter2 } from '@nestjs/event-emitter'; describe('Bitcoin Core Provider', () => { let provider: BitcoinCoreProvider; @@ -58,6 +59,10 @@ describe('Bitcoin Core Provider', () => { execute: jest.fn(), }, }, + { + provide: EventEmitter2, + useValue: jest.fn(), + }, ], }).compile(); diff --git a/src/block-data-providers/bitcoin-core/provider.ts b/src/block-data-providers/bitcoin-core/provider.ts index 303bc60..216f836 100644 --- a/src/block-data-providers/bitcoin-core/provider.ts +++ b/src/block-data-providers/bitcoin-core/provider.ts @@ -30,6 +30,8 @@ import * as currency from 'currency.js'; import { AxiosRetryConfig, makeRequest } from '@/common/request'; import { BlockStateService } from '@/block-state/block-state.service'; import { DbTransactionService } from '@/db-transaction/db-transaction.service'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { INDEXED_BLOCK_EVENT } from '@/common/events'; @Injectable() export class BitcoinCoreProvider @@ -48,6 +50,7 @@ export class BitcoinCoreProvider operationStateService: OperationStateService, blockStateService: BlockStateService, private readonly dbTransactionService: DbTransactionService, + protected readonly eventEmitter: EventEmitter2, ) { super( configService, @@ -156,6 +159,8 @@ export class BitcoinCoreProvider manager, ); }); + + this.eventEmitter.emit(INDEXED_BLOCK_EVENT, height); } } finally { this.isSyncing = false; diff --git a/src/block-data-providers/block-provider.module.ts b/src/block-data-providers/block-provider.module.ts index 93b18d1..2d966fd 100644 --- a/src/block-data-providers/block-provider.module.ts +++ b/src/block-data-providers/block-provider.module.ts @@ -11,6 +11,7 @@ import { BlockStateService } from '@/block-state/block-state.service'; import { BlockStateModule } from '@/block-state/block-state.module'; import { DbTransactionModule } from '@/db-transaction/db-transaction.module'; import { DbTransactionService } from '@/db-transaction/db-transaction.service'; +import { EventEmitter2, EventEmitterModule } from '@nestjs/event-emitter'; @Module({ imports: [ @@ -19,6 +20,7 @@ import { DbTransactionService } from '@/db-transaction/db-transaction.service'; ConfigModule, BlockStateModule, DbTransactionModule, + EventEmitterModule, ], controllers: [], providers: [ @@ -30,6 +32,7 @@ import { DbTransactionService } from '@/db-transaction/db-transaction.service'; OperationStateService, BlockStateService, DbTransactionService, + EventEmitter2, ], useFactory: ( configService: ConfigService, @@ -37,6 +40,7 @@ import { DbTransactionService } from '@/db-transaction/db-transaction.service'; operationStateService: OperationStateService, blockStateService: BlockStateService, dbTransactionService: DbTransactionService, + eventEmitter: EventEmitter2, ) => { switch (configService.get('providerType')) { case ProviderType.ESPLORA: @@ -46,6 +50,7 @@ import { DbTransactionService } from '@/db-transaction/db-transaction.service'; operationStateService, blockStateService, dbTransactionService, + eventEmitter, ); case ProviderType.BITCOIN_CORE_RPC: return new BitcoinCoreProvider( @@ -54,6 +59,7 @@ import { DbTransactionService } from '@/db-transaction/db-transaction.service'; operationStateService, blockStateService, dbTransactionService, + eventEmitter, ); default: throw Error('unrecognised provider type in config'); diff --git a/src/block-data-providers/esplora/provider.ts b/src/block-data-providers/esplora/provider.ts index b1536cb..72d1071 100644 --- a/src/block-data-providers/esplora/provider.ts +++ b/src/block-data-providers/esplora/provider.ts @@ -14,6 +14,8 @@ import { TAPROOT_ACTIVATION_HEIGHT } from '@/common/constants'; import { BlockStateService } from '@/block-state/block-state.service'; import { Cron, CronExpression } from '@nestjs/schedule'; import { DbTransactionService } from '@/db-transaction/db-transaction.service'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { INDEXED_BLOCK_EVENT } from '@/common/events'; @Injectable() export class EsploraProvider @@ -33,6 +35,7 @@ export class EsploraProvider operationStateService: OperationStateService, blockStateService: BlockStateService, private readonly dbTransactionService: DbTransactionService, + protected readonly eventEmitter: EventEmitter2, ) { super( configService, @@ -189,6 +192,8 @@ export class EsploraProvider manager, ); }); + + this.eventEmitter.emit(INDEXED_BLOCK_EVENT, height); } catch (error) { this.logger.error( `Error processing transactions in block at height ${height}, hash ${hash}: ${error.message}`, diff --git a/src/common/events.ts b/src/common/events.ts new file mode 100644 index 0000000..1b78d98 --- /dev/null +++ b/src/common/events.ts @@ -0,0 +1 @@ +export const INDEXED_BLOCK_EVENT = 'INDEXED_BLOCK_EVENT'; diff --git a/src/main.ts b/src/main.ts index 6488ce1..1eff3d0 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2,11 +2,13 @@ import { NestFactory } from '@nestjs/core'; import { AppModule } from '@/app.module'; import { ConfigService } from '@nestjs/config'; import { LogLevel } from '@nestjs/common'; +import { WsAdapter } from '@nestjs/platform-ws'; declare const module: any; async function bootstrap() { const app = await NestFactory.create(AppModule); + app.useWebSocketAdapter(new WsAdapter(app)); const configService = app.get(ConfigService); const port = configService.get('app.port'); diff --git a/src/silent-blocks/silent-blocks.gateway.ts b/src/silent-blocks/silent-blocks.gateway.ts new file mode 100644 index 0000000..8e6282d --- /dev/null +++ b/src/silent-blocks/silent-blocks.gateway.ts @@ -0,0 +1,36 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { + WebSocketGateway, + WebSocketServer, + OnGatewayConnection, + OnGatewayDisconnect, +} from '@nestjs/websockets'; +import { Server, WebSocket } from 'ws'; + +@Injectable() +@WebSocketGateway() +export class SilentBlocksGateway + implements OnGatewayConnection, OnGatewayDisconnect +{ + private readonly logger = new Logger(SilentBlocksGateway.name); + + @WebSocketServer() server: Server; + + handleConnection(client: WebSocket) { + const remoteAddress = (client as any)._socket.remoteAddress; + this.logger.debug(`Client connected: ${remoteAddress}`); + } + + handleDisconnect(client: WebSocket) { + const remoteAddress = (client as any)._socket.remoteAddress; + this.logger.debug(`Client disconnected: ${remoteAddress}`); + } + + broadcastSilentBlock(silentBlock: Buffer) { + for (const client of this.server.clients) { + if (client.readyState === WebSocket.OPEN) { + client.send(silentBlock); + } + } + } +} diff --git a/src/silent-blocks/silent-blocks.module.ts b/src/silent-blocks/silent-blocks.module.ts index 3e9d263..9127094 100644 --- a/src/silent-blocks/silent-blocks.module.ts +++ b/src/silent-blocks/silent-blocks.module.ts @@ -4,10 +4,11 @@ import { Transaction } from '@/transactions/transaction.entity'; import { TransactionsService } from '@/transactions/transactions.service'; import { SilentBlocksController } from '@/silent-blocks/silent-blocks.controller'; import { SilentBlocksService } from '@/silent-blocks/silent-blocks.service'; +import { SilentBlocksGateway } from '@/silent-blocks/silent-blocks.gateway'; @Module({ imports: [TypeOrmModule.forFeature([Transaction])], - providers: [TransactionsService, SilentBlocksService], + providers: [TransactionsService, SilentBlocksService, SilentBlocksGateway], controllers: [SilentBlocksController], exports: [SilentBlocksService], }) diff --git a/src/silent-blocks/silent-blocks.service.spec.ts b/src/silent-blocks/silent-blocks.service.spec.ts index b8cca86..90ce644 100644 --- a/src/silent-blocks/silent-blocks.service.spec.ts +++ b/src/silent-blocks/silent-blocks.service.spec.ts @@ -2,6 +2,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { TransactionsService } from '@/transactions/transactions.service'; import { SilentBlocksService } from '@/silent-blocks/silent-blocks.service'; import { silentBlockEncodingFixture } from '@/silent-blocks/silent-blocks.service.fixtures'; +import { SilentBlocksGateway } from '@/silent-blocks/silent-blocks.gateway'; import { DataSource, Repository } from 'typeorm'; import { Transaction } from '@/transactions/transaction.entity'; import { getRepositoryToken } from '@nestjs/typeorm'; @@ -31,6 +32,10 @@ describe('SilentBlocksService', () => { provide: getRepositoryToken(Transaction), useValue: transactionRepository, }, + { + provide: SilentBlocksGateway, + useValue: jest.fn(), + }, ], }).compile(); diff --git a/src/silent-blocks/silent-blocks.service.ts b/src/silent-blocks/silent-blocks.service.ts index 1da1d7c..71a4d08 100644 --- a/src/silent-blocks/silent-blocks.service.ts +++ b/src/silent-blocks/silent-blocks.service.ts @@ -1,12 +1,27 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, Logger } from '@nestjs/common'; import { Transaction } from '@/transactions/transaction.entity'; import { TransactionsService } from '@/transactions/transactions.service'; import { SILENT_PAYMENT_BLOCK_TYPE } from '@/common/constants'; import { encodeVarInt, varIntSize } from '@/common/common'; +import { SilentBlocksGateway } from '@/silent-blocks/silent-blocks.gateway'; +import { OnEvent } from '@nestjs/event-emitter'; +import { INDEXED_BLOCK_EVENT } from '@/common/events'; @Injectable() export class SilentBlocksService { - constructor(private readonly transactionsService: TransactionsService) {} + private readonly logger = new Logger(SilentBlocksService.name); + + constructor( + private readonly transactionsService: TransactionsService, + private readonly silentBlocksGateway: SilentBlocksGateway, + ) {} + + @OnEvent(INDEXED_BLOCK_EVENT) + async handleBlockIndexedEvent(blockHeight: number) { + this.logger.debug(`New block indexed: ${blockHeight}`); + const silentBlock = await this.getSilentBlockByHeight(blockHeight); + this.silentBlocksGateway.broadcastSilentBlock(silentBlock); + } private getSilentBlockLength(transactions: Transaction[]): number { let length = 1 + varIntSize(transactions.length); // 1 byte for type + varint for transactions count