diff --git a/libs/common/src/interceptors/transform.interceptor.ts b/libs/common/src/interceptors/transform.interceptor.ts index 0e04a31..2617890 100644 --- a/libs/common/src/interceptors/transform.interceptor.ts +++ b/libs/common/src/interceptors/transform.interceptor.ts @@ -18,9 +18,10 @@ export interface ApiResponse { * on a stable shape regardless of the controller that produced it. */ @Injectable() -export class TransformInterceptor - implements NestInterceptor> -{ +export class TransformInterceptor implements NestInterceptor< + T, + ApiResponse +> { intercept( _context: ExecutionContext, next: CallHandler, diff --git a/package-lock.json b/package-lock.json index b79b6d6..5b3bf96 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@nestjs/common": "^10.4.4", "@nestjs/config": "^3.2.3", "@nestjs/core": "^10.4.4", + "@nestjs/event-emitter": "^3.1.0", "@nestjs/jwt": "^10.2.0", "@nestjs/passport": "^10.0.3", "@nestjs/platform-express": "^10.4.4", @@ -2123,6 +2124,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": "10.2.0", "resolved": "https://registry.npmjs.org/@nestjs/jwt/-/jwt-10.2.0.tgz", @@ -5412,6 +5426,12 @@ "node": ">= 0.6" } }, + "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/events": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", diff --git a/package.json b/package.json index 769838f..ba79ace 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "@nestjs/common": "^10.4.4", "@nestjs/config": "^3.2.3", "@nestjs/core": "^10.4.4", + "@nestjs/event-emitter": "^3.1.0", "@nestjs/jwt": "^10.2.0", "@nestjs/passport": "^10.0.3", "@nestjs/platform-express": "^10.4.4", diff --git a/src/app.module.ts b/src/app.module.ts index 231278d..49f18e4 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -11,8 +11,10 @@ import { MarketplaceModule } from './modules/marketplace/marketplace.module'; import { AssetsModule } from './modules/assets/assets.module'; import { TransactionsModule } from './modules/transactions/transactions.module'; import { StellarModule } from './modules/stellar/stellar.module'; -import { TradingEngineModule } from './modules/trading-engine/trading-engine.module'; import { WalletModule } from './modules/wallet/wallet.module'; +import { NotificationsModule } from './modules/notifications/notifications.module'; +import { EventEmitterModule } from '@nestjs/event-emitter'; +import { TradingEngineModule } from './modules/trading-engine/trading-engine.module'; import { ErrorHandlerModule } from './modules/error-handler/error-handler.module'; import { ResilienceModule } from './modules/resilience/resilience.module'; import { QueueModule } from './modules/queue/queue.module'; @@ -21,6 +23,7 @@ import { BlockchainIndexerModule } from './modules/blockchain-indexer/blockchain @Module({ imports: [ + EventEmitterModule.forRoot(), ConfigModule, TypeOrmModule.forRootAsync({ useClass: DatabaseConfig, @@ -34,6 +37,7 @@ import { BlockchainIndexerModule } from './modules/blockchain-indexer/blockchain StellarModule, TradingEngineModule, WalletModule, + NotificationsModule, ErrorHandlerModule, ResilienceModule, QueueModule, diff --git a/src/config/configuration.ts b/src/config/configuration.ts index 254e086..9f7e938 100644 --- a/src/config/configuration.ts +++ b/src/config/configuration.ts @@ -46,7 +46,10 @@ export default () => ({ 10, ), // How long, and how often, we poll RPC for a submitted tx to settle. - pollIntervalMs: parseInt(process.env.SOROBAN_POLL_INTERVAL_MS ?? '1000', 10), + pollIntervalMs: parseInt( + process.env.SOROBAN_POLL_INTERVAL_MS ?? '1000', + 10, + ), pollTimeoutMs: parseInt(process.env.SOROBAN_POLL_TIMEOUT_MS ?? '30000', 10), // Contract state cache TTL (seconds) in Redis. stateCacheTtlSecs: parseInt( diff --git a/src/config/database.config.ts b/src/config/database.config.ts index 7724430..58cb323 100644 --- a/src/config/database.config.ts +++ b/src/config/database.config.ts @@ -1,9 +1,6 @@ import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; -import { - TypeOrmModuleOptions, - TypeOrmOptionsFactory, -} from '@nestjs/typeorm'; +import { TypeOrmModuleOptions, TypeOrmOptionsFactory } from '@nestjs/typeorm'; /** * Builds TypeORM connection options from validated configuration. Entities are diff --git a/src/modules/auth/auth.controller.ts b/src/modules/auth/auth.controller.ts index 7c08189..a0616a9 100644 --- a/src/modules/auth/auth.controller.ts +++ b/src/modules/auth/auth.controller.ts @@ -1,10 +1,21 @@ -import { Body, Controller, Get, HttpCode, HttpStatus, Post, UseGuards } from '@nestjs/common'; +import { + Body, + Controller, + Get, + HttpCode, + HttpStatus, + Post, + UseGuards, +} from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { AuthService } from './auth.service'; import { RegisterDto } from './dto/register.dto'; import { LoginDto } from './dto/login.dto'; import { JwtAuthGuard } from './guards/jwt-auth.guard'; -import { CurrentUser, AuthenticatedUser } from './decorators/current-user.decorator'; +import { + CurrentUser, + AuthenticatedUser, +} from './decorators/current-user.decorator'; @ApiTags('auth') @Controller('auth') diff --git a/src/modules/auth/auth.service.ts b/src/modules/auth/auth.service.ts index ec5673b..5e4d346 100644 --- a/src/modules/auth/auth.service.ts +++ b/src/modules/auth/auth.service.ts @@ -1,7 +1,4 @@ -import { - Injectable, - UnauthorizedException, -} from '@nestjs/common'; +import { Injectable, UnauthorizedException } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import * as bcrypt from 'bcryptjs'; import { UsersService } from '../users/users.service'; diff --git a/src/modules/marketplace/marketplace.service.ts b/src/modules/marketplace/marketplace.service.ts index 101dddc..9080404 100644 --- a/src/modules/marketplace/marketplace.service.ts +++ b/src/modules/marketplace/marketplace.service.ts @@ -28,9 +28,7 @@ export class MarketplaceService { return this.listingsRepository.save(listing); } - async findAll( - query: QueryListingDto, - ): Promise> { + async findAll(query: QueryListingDto): Promise> { const qb = this.listingsRepository .createQueryBuilder('listing') .leftJoinAndSelect('listing.seller', 'seller') diff --git a/src/modules/notifications/dto/create-notification-template.dto.ts b/src/modules/notifications/dto/create-notification-template.dto.ts new file mode 100644 index 0000000..2e4a48d --- /dev/null +++ b/src/modules/notifications/dto/create-notification-template.dto.ts @@ -0,0 +1,11 @@ +import { IsNotEmpty, IsString } from 'class-validator'; + +export class CreateNotificationTemplateDto { + @IsString() + @IsNotEmpty() + name: string; + + @IsString() + @IsNotEmpty() + template: string; +} diff --git a/src/modules/notifications/dto/search-notifications.dto.ts b/src/modules/notifications/dto/search-notifications.dto.ts new file mode 100644 index 0000000..e3e92df --- /dev/null +++ b/src/modules/notifications/dto/search-notifications.dto.ts @@ -0,0 +1,20 @@ +import { IsOptional, IsString, IsEnum, IsDateString } from 'class-validator'; +import { Channel } from '../enums/channel.enum'; + +export class SearchNotificationsDto { + @IsOptional() + @IsString() + userId?: string; + + @IsOptional() + @IsEnum(Channel) + channel?: Channel; + + @IsOptional() + @IsDateString() + startDate?: string; + + @IsOptional() + @IsDateString() + endDate?: string; +} diff --git a/src/modules/notifications/dto/send-from-template.dto.ts b/src/modules/notifications/dto/send-from-template.dto.ts new file mode 100644 index 0000000..e05731d --- /dev/null +++ b/src/modules/notifications/dto/send-from-template.dto.ts @@ -0,0 +1,18 @@ +import { IsEnum, IsNotEmpty, IsObject, IsString } from 'class-validator'; +import { Channel } from '../enums/channel.enum'; + +export class SendFromTemplateDto { + @IsString() + @IsNotEmpty() + templateName: string; + + @IsEnum(Channel) + channel: Channel; + + @IsString() + @IsNotEmpty() + recipient: string; + + @IsObject() + data: Record; +} diff --git a/src/modules/notifications/dto/test-notification.dto.ts b/src/modules/notifications/dto/test-notification.dto.ts new file mode 100644 index 0000000..36ee8bd --- /dev/null +++ b/src/modules/notifications/dto/test-notification.dto.ts @@ -0,0 +1,6 @@ +import { IsString } from 'class-validator'; + +export class TestNotificationDto { + @IsString() + message: string; +} diff --git a/src/modules/notifications/dto/update-notification-preference.dto.ts b/src/modules/notifications/dto/update-notification-preference.dto.ts new file mode 100644 index 0000000..bac7e96 --- /dev/null +++ b/src/modules/notifications/dto/update-notification-preference.dto.ts @@ -0,0 +1,10 @@ +import { IsBoolean, IsEnum } from 'class-validator'; +import { Channel } from '../enums/channel.enum'; + +export class UpdateNotificationPreferenceDto { + @IsEnum(Channel) + channel: Channel; + + @IsBoolean() + isEnabled: boolean; +} diff --git a/src/modules/notifications/entities/notification-preference.entity.ts b/src/modules/notifications/entities/notification-preference.entity.ts new file mode 100644 index 0000000..4796b6e --- /dev/null +++ b/src/modules/notifications/entities/notification-preference.entity.ts @@ -0,0 +1,32 @@ +import { + Column, + Entity, + PrimaryGeneratedColumn, + CreateDateColumn, + UpdateDateColumn, +} from 'typeorm'; +import { Channel } from '../enums/channel.enum'; + +@Entity('notification_preferences') +export class NotificationPreference { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column() + userId: string; + + @Column({ + type: 'enum', + enum: Channel, + }) + channel: Channel; + + @Column({ default: true }) + isEnabled: boolean; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/src/modules/notifications/entities/notification-template.entity.ts b/src/modules/notifications/entities/notification-template.entity.ts new file mode 100644 index 0000000..2e1f6b7 --- /dev/null +++ b/src/modules/notifications/entities/notification-template.entity.ts @@ -0,0 +1,25 @@ +import { + Column, + CreateDateColumn, + Entity, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; + +@Entity('notification_templates') +export class NotificationTemplate { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ unique: true }) + name: string; + + @Column('text') + template: string; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/src/modules/notifications/entities/notification.entity.ts b/src/modules/notifications/entities/notification.entity.ts new file mode 100644 index 0000000..ea35f74 --- /dev/null +++ b/src/modules/notifications/entities/notification.entity.ts @@ -0,0 +1,35 @@ +import { + Column, + CreateDateColumn, + Entity, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; +import { Channel } from '../enums/channel.enum'; + +@Entity('notifications') +export class Notification { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column() + recipient: string; + + @Column({ + type: 'enum', + enum: Channel, + }) + channel: Channel; + + @Column('text') + message: string; + + @Column({ default: false }) + isRead: boolean; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/src/modules/notifications/enums/channel.enum.ts b/src/modules/notifications/enums/channel.enum.ts new file mode 100644 index 0000000..2d1f2ff --- /dev/null +++ b/src/modules/notifications/enums/channel.enum.ts @@ -0,0 +1,5 @@ +export enum Channel { + WEB_SOCKET = 'web_socket', + EMAIL = 'email', + SMS = 'sms', +} diff --git a/src/modules/notifications/events/notification.events.ts b/src/modules/notifications/events/notification.events.ts new file mode 100644 index 0000000..3b124b2 --- /dev/null +++ b/src/modules/notifications/events/notification.events.ts @@ -0,0 +1,3 @@ +export class NotificationEvents { + static readonly SEND_NOTIFICATION = 'notification.send'; +} diff --git a/src/modules/notifications/listeners/notification.listener.ts b/src/modules/notifications/listeners/notification.listener.ts new file mode 100644 index 0000000..379c03c --- /dev/null +++ b/src/modules/notifications/listeners/notification.listener.ts @@ -0,0 +1,15 @@ +import { Injectable } from '@nestjs/common'; +import { OnEvent } from '@nestjs/event-emitter'; +import { NotificationEvents } from '../events/notification.events'; +import { NotificationsService } from '../notifications.service'; +import { Notification } from '../notification.class'; + +@Injectable() +export class NotificationListener { + constructor(private readonly notificationsService: NotificationsService) {} + + @OnEvent(NotificationEvents.SEND_NOTIFICATION) + handleSendNotificationEvent(notification: Notification) { + this.notificationsService.send(notification); + } +} diff --git a/src/modules/notifications/notification.class.ts b/src/modules/notifications/notification.class.ts new file mode 100644 index 0000000..23a46d5 --- /dev/null +++ b/src/modules/notifications/notification.class.ts @@ -0,0 +1,13 @@ +import { Channel } from './enums/channel.enum'; + +export class Notification { + channel: Channel; + recipient: string; + message: string; + + constructor(channel: Channel, recipient: string, message: string) { + this.channel = channel; + this.recipient = recipient; + this.message = message; + } +} diff --git a/src/modules/notifications/notifications.controller.ts b/src/modules/notifications/notifications.controller.ts new file mode 100644 index 0000000..dd125f0 --- /dev/null +++ b/src/modules/notifications/notifications.controller.ts @@ -0,0 +1,70 @@ +import { Controller, Post, Body, Get, Put, Param, Query } from '@nestjs/common'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { NotificationsService } from './notifications.service'; +import { NotificationEvents } from './events/notification.events'; +import { Notification } from './notification.class'; +import { Channel } from './enums/channel.enum'; +import { TestNotificationDto } from './dto/test-notification.dto'; +import { UpdateNotificationPreferenceDto } from './dto/update-notification-preference.dto'; +import { CreateNotificationTemplateDto } from './dto/create-notification-template.dto'; +import { SendFromTemplateDto } from './dto/send-from-template.dto'; +import { SearchNotificationsDto } from './dto/search-notifications.dto'; + +@Controller('notifications') +export class NotificationsController { + constructor( + private readonly notificationsService: NotificationsService, + private readonly eventEmitter: EventEmitter2, + ) {} + + @Get('history') + searchHistory(@Query() searchDto: SearchNotificationsDto) { + return this.notificationsService.search(searchDto); + } + + @Post('test') + testNotification(@Body() testNotificationDto: TestNotificationDto) { + const notification = new Notification( + Channel.WEB_SOCKET, + 'test-user', + testNotificationDto.message, + ); + this.eventEmitter.emit(NotificationEvents.SEND_NOTIFICATION, notification); + return { message: 'Notification sent!' }; + } + + @Post('templates') + createTemplate(@Body() createTemplateDto: CreateNotificationTemplateDto) { + return this.notificationsService.createTemplate( + createTemplateDto.name, + createTemplateDto.template, + ); + } + + @Post('send-from-template') + sendFromTemplate(@Body() sendFromTemplateDto: SendFromTemplateDto) { + return this.notificationsService.sendFromTemplate( + sendFromTemplateDto.templateName, + sendFromTemplateDto.channel, + sendFromTemplateDto.recipient, + sendFromTemplateDto.data, + ); + } + + @Get('preferences/:userId') + getPreferences(@Param('userId') userId: string) { + return this.notificationsService.getUserPreferences(userId); + } + + @Put('preferences/:userId') + updatePreference( + @Param('userId') userId: string, + @Body() updatePreferenceDto: UpdateNotificationPreferenceDto, + ) { + return this.notificationsService.updateUserPreference( + userId, + updatePreferenceDto.channel, + updatePreferenceDto.isEnabled, + ); + } +} diff --git a/src/modules/notifications/notifications.module.ts b/src/modules/notifications/notifications.module.ts new file mode 100644 index 0000000..e7c2789 --- /dev/null +++ b/src/modules/notifications/notifications.module.ts @@ -0,0 +1,33 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { NotificationsService } from './notifications.service'; +import { NotificationsController } from './notifications.controller'; +import { NotificationStrategy } from './providers/notification.strategy'; +import { WebSocketNotificationProvider } from './providers/websocket-notification.provider'; +import { EmailNotificationProvider } from './providers/email-notification.provider'; +import { SmsNotificationProvider } from './providers/sms-notification.provider'; +import { NotificationListener } from './listeners/notification.listener'; +import { Notification } from './entities/notification.entity'; +import { NotificationPreference } from './entities/notification-preference.entity'; +import { NotificationTemplate } from './entities/notification-template.entity'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([ + Notification, + NotificationPreference, + NotificationTemplate, + ]), + ], + controllers: [NotificationsController], + providers: [ + NotificationsService, + NotificationStrategy, + WebSocketNotificationProvider, + EmailNotificationProvider, + SmsNotificationProvider, + NotificationListener, + ], + exports: [NotificationsService], +}) +export class NotificationsModule {} diff --git a/src/modules/notifications/notifications.service.ts b/src/modules/notifications/notifications.service.ts new file mode 100644 index 0000000..3561644 --- /dev/null +++ b/src/modules/notifications/notifications.service.ts @@ -0,0 +1,162 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Notification as NotificationEntity } from './entities/notification.entity'; +import { NotificationPreference } from './entities/notification-preference.entity'; +import { NotificationTemplate } from './entities/notification-template.entity'; +import { Notification } from './notification.class'; +import { NotificationStrategy } from './providers/notification.strategy'; +import { Channel } from './enums/channel.enum'; + +@Injectable() +export class NotificationsService { + constructor( + @InjectRepository(NotificationEntity) + private readonly notificationRepository: Repository, + @InjectRepository(NotificationPreference) + private readonly notificationPreferenceRepository: Repository, + @InjectRepository(NotificationTemplate) + private readonly notificationTemplateRepository: Repository, + private readonly notificationStrategy: NotificationStrategy, + ) {} + + async send(notification: Notification): Promise { + const userPreference = await this.notificationPreferenceRepository.findOne({ + where: { + userId: notification.recipient, + channel: notification.channel, + }, + }); + + if (userPreference && !userPreference.isEnabled) { + return; // User has disabled this channel + } + + await this.notificationRepository.save({ + recipient: notification.recipient, + channel: notification.channel, + message: notification.message, + }); + + const provider = this.notificationStrategy.getProvider( + notification.channel, + ); + + let attempts = 0; + const maxAttempts = 5; + let delay = 1000; + + while (attempts < maxAttempts) { + try { + await provider.send(notification); + return; // Success + } catch (error) { + attempts++; + if (attempts >= maxAttempts) { + // Log the final failure + console.error( + `Failed to send notification after ${maxAttempts} attempts`, + error, + ); + break; + } + await new Promise((resolve) => setTimeout(resolve, delay)); + delay *= 2; // Exponential backoff + } + } + } + + async createTemplate( + name: string, + template: string, + ): Promise { + const newTemplate = this.notificationTemplateRepository.create({ + name, + template, + }); + return this.notificationTemplateRepository.save(newTemplate); + } + + async sendFromTemplate( + templateName: string, + channel: Channel, + recipient: string, + data: Record, + ): Promise { + const template = await this.notificationTemplateRepository.findOne({ + where: { name: templateName }, + }); + if (!template) { + throw new NotFoundException( + `Template with name ${templateName} not found`, + ); + } + + const message = this.renderTemplate(template.template, data); + const notification = new Notification(channel, recipient, message); + await this.send(notification); + } + + private renderTemplate(template: string, data: Record): string { + return template.replace(/{{(\w+)}}/g, (placeholder, key) => { + return data[key] || placeholder; + }); + } + + async search(searchDto: any): Promise { + const query = + this.notificationRepository.createQueryBuilder('notification'); + + if (searchDto.userId) { + query.andWhere('notification.recipient = :userId', { + userId: searchDto.userId, + }); + } + + if (searchDto.channel) { + query.andWhere('notification.channel = :channel', { + channel: searchDto.channel, + }); + } + + if (searchDto.startDate) { + query.andWhere('notification.createdAt >= :startDate', { + startDate: searchDto.startDate, + }); + } + + if (searchDto.endDate) { + query.andWhere('notification.createdAt <= :endDate', { + endDate: searchDto.endDate, + }); + } + + return query.getMany(); + } + + async getUserPreferences(userId: string): Promise { + return this.notificationPreferenceRepository.find({ where: { userId } }); + } + + async updateUserPreference( + userId: string, + channel: Channel, + isEnabled: boolean, + ): Promise { + let preference = await this.notificationPreferenceRepository.findOne({ + where: { userId, channel }, + }); + + if (preference) { + preference.isEnabled = isEnabled; + } else { + preference = this.notificationPreferenceRepository.create({ + userId, + channel, + isEnabled, + }); + } + + return this.notificationPreferenceRepository.save(preference); + } +} diff --git a/src/modules/notifications/providers/email-notification.provider.ts b/src/modules/notifications/providers/email-notification.provider.ts new file mode 100644 index 0000000..2ceed70 --- /dev/null +++ b/src/modules/notifications/providers/email-notification.provider.ts @@ -0,0 +1,12 @@ +import { Injectable } from '@nestjs/common'; +import { Notification } from '../notification.class'; +import { NotificationProvider } from './notification.provider'; + +@Injectable() +export class EmailNotificationProvider implements NotificationProvider { + async send(notification: Notification): Promise { + console.log( + `Sending Email notification to ${notification.recipient}: ${notification.message}`, + ); + } +} diff --git a/src/modules/notifications/providers/notification.provider.ts b/src/modules/notifications/providers/notification.provider.ts new file mode 100644 index 0000000..333aa66 --- /dev/null +++ b/src/modules/notifications/providers/notification.provider.ts @@ -0,0 +1,5 @@ +import { Notification } from '../notification.class'; + +export interface NotificationProvider { + send(notification: Notification): Promise; +} diff --git a/src/modules/notifications/providers/notification.strategy.ts b/src/modules/notifications/providers/notification.strategy.ts new file mode 100644 index 0000000..f431a0b --- /dev/null +++ b/src/modules/notifications/providers/notification.strategy.ts @@ -0,0 +1,29 @@ +import { Injectable } from '@nestjs/common'; +import { Channel } from '../enums/channel.enum'; +import { NotificationProvider } from './notification.provider'; +import { WebSocketNotificationProvider } from './websocket-notification.provider'; +import { EmailNotificationProvider } from './email-notification.provider'; +import { SmsNotificationProvider } from './sms-notification.provider'; + +@Injectable() +export class NotificationStrategy { + private providers: Map = new Map(); + + constructor( + private readonly webSocketProvider: WebSocketNotificationProvider, + private readonly emailProvider: EmailNotificationProvider, + private readonly smsProvider: SmsNotificationProvider, + ) { + this.providers.set(Channel.WEB_SOCKET, this.webSocketProvider); + this.providers.set(Channel.EMAIL, this.emailProvider); + this.providers.set(Channel.SMS, this.smsProvider); + } + + getProvider(channel: Channel): NotificationProvider { + const provider = this.providers.get(channel); + if (!provider) { + throw new Error(`Provider for channel ${channel} not found`); + } + return provider; + } +} diff --git a/src/modules/notifications/providers/sms-notification.provider.ts b/src/modules/notifications/providers/sms-notification.provider.ts new file mode 100644 index 0000000..b654e79 --- /dev/null +++ b/src/modules/notifications/providers/sms-notification.provider.ts @@ -0,0 +1,12 @@ +import { Injectable } from '@nestjs/common'; +import { Notification } from '../notification.class'; +import { NotificationProvider } from './notification.provider'; + +@Injectable() +export class SmsNotificationProvider implements NotificationProvider { + async send(notification: Notification): Promise { + console.log( + `Sending SMS notification to ${notification.recipient}: ${notification.message}`, + ); + } +} diff --git a/src/modules/notifications/providers/websocket-notification.provider.ts b/src/modules/notifications/providers/websocket-notification.provider.ts new file mode 100644 index 0000000..fae22f4 --- /dev/null +++ b/src/modules/notifications/providers/websocket-notification.provider.ts @@ -0,0 +1,12 @@ +import { Injectable } from '@nestjs/common'; +import { Notification } from '../notification.class'; +import { NotificationProvider } from './notification.provider'; + +@Injectable() +export class WebSocketNotificationProvider implements NotificationProvider { + async send(notification: Notification): Promise { + console.log( + `Sending WebSocket notification to ${notification.recipient}: ${notification.message}`, + ); + } +} diff --git a/src/modules/stellar/dto/invoke-contract.dto.ts b/src/modules/stellar/dto/invoke-contract.dto.ts index 8619e72..531d0cd 100644 --- a/src/modules/stellar/dto/invoke-contract.dto.ts +++ b/src/modules/stellar/dto/invoke-contract.dto.ts @@ -1,10 +1,5 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { - IsArray, - IsOptional, - IsString, - Matches, -} from 'class-validator'; +import { IsArray, IsOptional, IsString, Matches } from 'class-validator'; /** * Request to invoke a read-only Soroban contract method. Arguments are passed diff --git a/src/modules/stellar/gateway/dto/settlement-request.dto.ts b/src/modules/stellar/gateway/dto/settlement-request.dto.ts index 35e21a5..97c3b0a 100644 --- a/src/modules/stellar/gateway/dto/settlement-request.dto.ts +++ b/src/modules/stellar/gateway/dto/settlement-request.dto.ts @@ -47,4 +47,4 @@ export class SettlementRequestDto { @IsString() @IsNotEmpty() amount: string; -} \ No newline at end of file +} diff --git a/src/modules/stellar/gateway/dto/submit-transaction.dto.ts b/src/modules/stellar/gateway/dto/submit-transaction.dto.ts index 0de97cc..7a038c8 100644 --- a/src/modules/stellar/gateway/dto/submit-transaction.dto.ts +++ b/src/modules/stellar/gateway/dto/submit-transaction.dto.ts @@ -10,4 +10,4 @@ export class SubmitTransactionDto { @IsString() @IsNotEmpty() transactionXdr: string; -} \ No newline at end of file +} diff --git a/src/modules/stellar/gateway/index.ts b/src/modules/stellar/gateway/index.ts index ab4bd7b..bb3a169 100644 --- a/src/modules/stellar/gateway/index.ts +++ b/src/modules/stellar/gateway/index.ts @@ -4,4 +4,4 @@ export * from './stellar-request-queue.service'; export * from './stellar-api-gateway.service'; export * from './stellar-gateway.controller'; export * from './dto/settlement-request.dto'; -export * from './dto/submit-transaction.dto'; \ No newline at end of file +export * from './dto/submit-transaction.dto'; diff --git a/src/modules/stellar/gateway/stellar-api-gateway.service.spec.ts b/src/modules/stellar/gateway/stellar-api-gateway.service.spec.ts index 90c7b34..53d8108 100644 --- a/src/modules/stellar/gateway/stellar-api-gateway.service.spec.ts +++ b/src/modules/stellar/gateway/stellar-api-gateway.service.spec.ts @@ -7,9 +7,6 @@ import { StellarApiGatewayService } from './stellar-api-gateway.service'; describe('StellarApiGatewayService', () => { let service: StellarApiGatewayService; - let connectionPool: StellarConnectionPoolService; - let rateLimiter: StellarRateLimiterService; - let requestQueue: StellarRequestQueueService; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ @@ -24,7 +21,8 @@ describe('StellarApiGatewayService', () => { get: jest.fn((key: string) => { const config: Record = { 'stellar.horizonUrl': 'https://horizon-testnet.stellar.org', - 'stellar.networkPassphrase': 'Test SDF Network ; September 2015', + 'stellar.networkPassphrase': + 'Test SDF Network ; September 2015', 'stellar.network': 'testnet', 'stellar.poolMinConnections': 2, 'stellar.poolMaxConnections': 10, @@ -41,9 +39,6 @@ describe('StellarApiGatewayService', () => { }).compile(); service = module.get(StellarApiGatewayService); - connectionPool = module.get(StellarConnectionPoolService); - rateLimiter = module.get(StellarRateLimiterService); - requestQueue = module.get(StellarRequestQueueService); }); it('should be defined', () => { @@ -63,4 +58,4 @@ describe('StellarApiGatewayService', () => { expect(stats.queue).toBeDefined(); expect(stats.rateLimit).toBeDefined(); }); -}); \ No newline at end of file +}); diff --git a/src/modules/stellar/gateway/stellar-api-gateway.service.ts b/src/modules/stellar/gateway/stellar-api-gateway.service.ts index 0ffb005..54f732b 100644 --- a/src/modules/stellar/gateway/stellar-api-gateway.service.ts +++ b/src/modules/stellar/gateway/stellar-api-gateway.service.ts @@ -1,7 +1,16 @@ -import { Injectable, Logger, ServiceUnavailableException } from '@nestjs/common'; +import { Injectable, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; -import { Networks, TransactionBuilder, Operation, Asset, Horizon, xdr } from '@stellar/stellar-sdk'; -import { StellarConnectionPoolService, PooledConnection } from './stellar-connection-pool.service'; +import { + Networks, + TransactionBuilder, + Operation, + Asset, + Horizon, +} from '@stellar/stellar-sdk'; +import { + StellarConnectionPoolService, + PooledConnection, +} from './stellar-connection-pool.service'; import { StellarRateLimiterService } from './stellar-rate-limiter.service'; import { StellarRequestQueueService } from './stellar-request-queue.service'; @@ -55,7 +64,7 @@ export class StellarApiGatewayService { this.networkPassphrase = this.configService.get('stellar.networkPassphrase') ?? Networks.TESTNET; - + this.logger.log('Stellar API Gateway initialized successfully'); } @@ -69,18 +78,18 @@ export class StellarApiGatewayService { operation: () => Promise, ): Promise> { const startTime = Date.now(); - + try { this.rateLimiter.checkRateLimit(clientId); - + const queueStats = this.requestQueue.getQueueStats(); this.rateLimiter.checkBurstLimit(queueStats.activeRequests); const result = await operation(); - + const latency = Date.now() - startTime; this.logger.log(`Request ${requestId} completed in ${latency}ms`); - + return { success: true, data: result, @@ -91,9 +100,11 @@ export class StellarApiGatewayService { } catch (error) { const latency = Date.now() - startTime; const errorMessage = (error as Error).message; - - this.logger.error(`Request ${requestId} failed after ${latency}ms: ${errorMessage}`); - + + this.logger.error( + `Request ${requestId} failed after ${latency}ms: ${errorMessage}`, + ); + return { success: false, error: errorMessage, @@ -104,19 +115,24 @@ export class StellarApiGatewayService { } } - async getAccount(accountId: string, clientId: string): Promise> { + async getAccount( + accountId: string, + clientId: string, + ): Promise> { const requestId = this.generateRequestId(); - + return this.requestQueue.enqueue(clientId, async () => { let connection: PooledConnection | null = null; - + try { connection = await this.connectionPool.acquireConnection(); - this.logger.debug(`Executing getAccount for ${accountId} with connection ${connection.id}`); - + this.logger.debug( + `Executing getAccount for ${accountId} with connection ${connection.id}`, + ); + const account = await connection.server.loadAccount(accountId); this.connectionPool.releaseConnection(connection.id); - + const balances = account.balances.map((b) => ({ assetType: b.asset_type, assetCode: 'asset_code' in b ? b.asset_code : undefined, @@ -143,42 +159,53 @@ export class StellarApiGatewayService { clientId: string, ): Promise> { const requestId = this.generateRequestId(); - + return this.requestQueue.enqueue(clientId, async () => { let connection: PooledConnection | null = null; - + try { connection = await this.connectionPool.acquireConnection(); - this.logger.debug(`Executing settlement for ${settlementRequest.fromAccount} -> ${settlementRequest.toAccount} with connection ${connection.id}`); - - const { fromAccount, toAccount, assetCode, assetIssuer, amount } = settlementRequest; - + this.logger.debug( + `Executing settlement for ${settlementRequest.fromAccount} -> ${settlementRequest.toAccount} with connection ${connection.id}`, + ); + + const { fromAccount, toAccount, assetCode, assetIssuer, amount } = + settlementRequest; + const sourceAccount = await connection.server.loadAccount(fromAccount); - - const asset = assetIssuer + + const asset = assetIssuer ? new Asset(assetCode, assetIssuer) : Asset.native(); const transferAmount = parseFloat(amount).toFixed(7); - const transaction = new TransactionBuilder(sourceAccount, { + new TransactionBuilder(sourceAccount, { networkPassphrase: this.networkPassphrase, fee: '100', }) - .addOperation(Operation.payment({ - destination: toAccount, - asset: asset, - amount: transferAmount, - })) + .addOperation( + Operation.payment({ + destination: toAccount, + asset: asset, + amount: transferAmount, + }), + ) .setTimeout(30) .build(); this.connectionPool.releaseConnection(connection.id); - + const mockTxHash = `settlement_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; - this.logger.log(`Settlement transaction ${mockTxHash} created for trade`); + this.logger.log( + `Settlement transaction ${mockTxHash} created for trade`, + ); - return this.executeWithMonitoring(requestId, clientId, async () => mockTxHash); + return this.executeWithMonitoring( + requestId, + clientId, + async () => mockTxHash, + ); } catch (error) { if (connection) { this.connectionPool.releaseConnection(connection.id); @@ -193,18 +220,27 @@ export class StellarApiGatewayService { clientId: string, ): Promise> { const requestId = this.generateRequestId(); - + return this.requestQueue.enqueue(clientId, async () => { let connection: PooledConnection | null = null; - + try { connection = await this.connectionPool.acquireConnection(); - this.logger.debug(`Fetching transaction ${transactionHash} with connection ${connection.id}`); - - const transaction = await connection.server.transactions().transaction(transactionHash).call(); + this.logger.debug( + `Fetching transaction ${transactionHash} with connection ${connection.id}`, + ); + + const transaction = await connection.server + .transactions() + .transaction(transactionHash) + .call(); this.connectionPool.releaseConnection(connection.id); - return this.executeWithMonitoring(requestId, clientId, async () => transaction); + return this.executeWithMonitoring( + requestId, + clientId, + async () => transaction, + ); } catch (error) { if (connection) { this.connectionPool.releaseConnection(connection.id); @@ -219,19 +255,27 @@ export class StellarApiGatewayService { clientId: string, ): Promise> { const requestId = this.generateRequestId(); - + return this.requestQueue.enqueue(clientId, async () => { let connection: PooledConnection | null = null; - + try { connection = await this.connectionPool.acquireConnection(); - this.logger.debug(`Submitting transaction with connection ${connection.id}`); - + this.logger.debug( + `Submitting transaction with connection ${connection.id}`, + ); + // In this Stellar SDK version, submitTransaction accepts XDR strings as well - const result = await connection.server.submitTransaction(transactionXdr as any); + const result = await connection.server.submitTransaction( + transactionXdr as any, + ); this.connectionPool.releaseConnection(connection.id); - return this.executeWithMonitoring(requestId, clientId, async () => result); + return this.executeWithMonitoring( + requestId, + clientId, + async () => result, + ); } catch (error) { if (connection) { this.connectionPool.releaseConnection(connection.id); @@ -258,4 +302,4 @@ export class StellarApiGatewayService { horizonUrl: this.configService.get('stellar.horizonUrl')!, }; } -} \ No newline at end of file +} diff --git a/src/modules/stellar/gateway/stellar-connection-pool.service.spec.ts b/src/modules/stellar/gateway/stellar-connection-pool.service.spec.ts index acadb69..51f87bb 100644 --- a/src/modules/stellar/gateway/stellar-connection-pool.service.spec.ts +++ b/src/modules/stellar/gateway/stellar-connection-pool.service.spec.ts @@ -25,7 +25,9 @@ describe('StellarConnectionPoolService', () => { ], }).compile(); - service = module.get(StellarConnectionPoolService); + service = module.get( + StellarConnectionPoolService, + ); }); it('should be defined', () => { @@ -43,10 +45,10 @@ describe('StellarConnectionPoolService', () => { const connection = await service.acquireConnection(); expect(connection).toBeDefined(); expect(connection.inUse).toBe(true); - + service.releaseConnection(connection.id); - + const stats = service.getPoolStats(); expect(stats.idle).toBeGreaterThan(0); }); -}); \ No newline at end of file +}); diff --git a/src/modules/stellar/gateway/stellar-connection-pool.service.ts b/src/modules/stellar/gateway/stellar-connection-pool.service.ts index 829c690..993e322 100644 --- a/src/modules/stellar/gateway/stellar-connection-pool.service.ts +++ b/src/modules/stellar/gateway/stellar-connection-pool.service.ts @@ -29,10 +29,15 @@ export class StellarConnectionPoolService { constructor(private readonly configService: ConfigService) { this.horizonUrl = this.configService.get('stellar.horizonUrl')!; this.config = { - minConnections: this.configService.get('stellar.poolMinConnections') ?? 2, - maxConnections: this.configService.get('stellar.poolMaxConnections') ?? 10, - idleTimeoutMs: this.configService.get('stellar.poolIdleTimeoutMs') ?? 30000, - connectionTtlMs: this.configService.get('stellar.poolConnectionTtlMs') ?? 3600000, + minConnections: + this.configService.get('stellar.poolMinConnections') ?? 2, + maxConnections: + this.configService.get('stellar.poolMaxConnections') ?? 10, + idleTimeoutMs: + this.configService.get('stellar.poolIdleTimeoutMs') ?? 30000, + connectionTtlMs: + this.configService.get('stellar.poolConnectionTtlMs') ?? + 3600000, }; this.initializePool(); @@ -40,11 +45,15 @@ export class StellarConnectionPoolService { } private initializePool(): void { - this.logger.log(`Initializing Stellar connection pool with min=${this.config.minConnections}, max=${this.config.maxConnections}`); + this.logger.log( + `Initializing Stellar connection pool with min=${this.config.minConnections}, max=${this.config.maxConnections}`, + ); for (let i = 0; i < this.config.minConnections; i++) { this.createConnection(); } - this.logger.log(`Connection pool initialized with ${this.connections.size} connections`); + this.logger.log( + `Connection pool initialized with ${this.connections.size} connections`, + ); } private createConnection(): PooledConnection { @@ -58,18 +67,24 @@ export class StellarConnectionPoolService { id, }; this.connections.set(id, connection); - this.logger.debug(`Created new connection ${id}, total connections: ${this.connections.size}`); + this.logger.debug( + `Created new connection ${id}, total connections: ${this.connections.size}`, + ); return connection; } async acquireConnection(): Promise { - const availableConnection = Array.from(this.connections.values()).find(conn => !conn.inUse); - + const availableConnection = Array.from(this.connections.values()).find( + (conn) => !conn.inUse, + ); + if (availableConnection) { availableConnection.inUse = true; availableConnection.lastUsed = new Date(); availableConnection.usageCount++; - this.logger.debug(`Acquired existing connection ${availableConnection.id}`); + this.logger.debug( + `Acquired existing connection ${availableConnection.id}`, + ); return availableConnection; } @@ -77,14 +92,18 @@ export class StellarConnectionPoolService { const newConnection = this.createConnection(); newConnection.inUse = true; newConnection.usageCount++; - this.logger.debug(`Acquired new connection ${newConnection.id}, pool size: ${this.connections.size}`); + this.logger.debug( + `Acquired new connection ${newConnection.id}, pool size: ${this.connections.size}`, + ); return newConnection; } this.logger.warn('Pool exhausted, waiting for available connection...'); return new Promise((resolve) => { const checkInterval = setInterval(() => { - const conn = Array.from(this.connections.values()).find(c => !c.inUse); + const conn = Array.from(this.connections.values()).find( + (c) => !c.inUse, + ); if (conn) { clearInterval(checkInterval); conn.inUse = true; @@ -117,11 +136,17 @@ export class StellarConnectionPoolService { let removedCount = 0; for (const [id, connection] of this.connections.entries()) { - if (!connection.inUse && this.connections.size > this.config.minConnections) { + if ( + !connection.inUse && + this.connections.size > this.config.minConnections + ) { const idleTime = now.getTime() - connection.lastUsed.getTime(); const age = now.getTime() - connection.created.getTime(); - - if (idleTime > this.config.idleTimeoutMs || age > this.config.connectionTtlMs) { + + if ( + idleTime > this.config.idleTimeoutMs || + age > this.config.connectionTtlMs + ) { this.connections.delete(id); removedCount++; this.logger.debug(`Removed idle/expired connection ${id}`); @@ -130,13 +155,20 @@ export class StellarConnectionPoolService { } if (removedCount > 0) { - this.logger.log(`Cleaned up ${removedCount} connections, current pool size: ${this.connections.size}`); + this.logger.log( + `Cleaned up ${removedCount} connections, current pool size: ${this.connections.size}`, + ); } } - getPoolStats(): { total: number; active: number; idle: number; config: ConnectionPoolConfig } { + getPoolStats(): { + total: number; + active: number; + idle: number; + config: ConnectionPoolConfig; + } { const connections = Array.from(this.connections.values()); - const active = connections.filter(c => c.inUse).length; + const active = connections.filter((c) => c.inUse).length; return { total: connections.length, active, @@ -151,4 +183,4 @@ export class StellarConnectionPoolService { } this.connections.clear(); } -} \ No newline at end of file +} diff --git a/src/modules/stellar/gateway/stellar-gateway.controller.ts b/src/modules/stellar/gateway/stellar-gateway.controller.ts index d1b2685..5880cdf 100644 --- a/src/modules/stellar/gateway/stellar-gateway.controller.ts +++ b/src/modules/stellar/gateway/stellar-gateway.controller.ts @@ -1,9 +1,9 @@ -import { - Controller, - Get, - Post, - Param, - Body, +import { + Controller, + Get, + Post, + Param, + Body, Headers, HttpException, HttpStatus, @@ -24,19 +24,27 @@ export class StellarGatewayController { @Get('network') @ApiOperation({ summary: 'Get configured Stellar network information' }) - @ApiResponse({ status: 200, description: 'Network information retrieved successfully' }) + @ApiResponse({ + status: 200, + description: 'Network information retrieved successfully', + }) getNetworkInfo() { return this.apiGateway.getNetworkInfo(); } @Get('accounts/:accountId') - @ApiOperation({ summary: 'Fetch account summary and balances from Stellar network' }) + @ApiOperation({ + summary: 'Fetch account summary and balances from Stellar network', + }) @ApiHeader({ name: 'X-Client-ID', description: 'Client identifier for rate limiting', required: false, }) - @ApiResponse({ status: 200, description: 'Account information retrieved successfully' }) + @ApiResponse({ + status: 200, + description: 'Account information retrieved successfully', + }) @ApiResponse({ status: 429, description: 'Rate limit exceeded' }) async getAccount( @Param('accountId') accountId: string, @@ -44,34 +52,48 @@ export class StellarGatewayController { ) { const clientId = this.extractClientId(headers); const response = await this.apiGateway.getAccount(accountId, clientId); - + if (!response.success) { - throw new HttpException(response.error || 'Unknown error occurred', HttpStatus.SERVICE_UNAVAILABLE); + throw new HttpException( + response.error || 'Unknown error occurred', + HttpStatus.SERVICE_UNAVAILABLE, + ); } - + return response; } @Post('settle') - @ApiOperation({ summary: 'Execute a settlement transaction between two accounts' }) + @ApiOperation({ + summary: 'Execute a settlement transaction between two accounts', + }) @ApiHeader({ name: 'X-Client-ID', description: 'Client identifier for rate limiting', required: false, }) - @ApiResponse({ status: 200, description: 'Settlement transaction created successfully' }) + @ApiResponse({ + status: 200, + description: 'Settlement transaction created successfully', + }) @ApiResponse({ status: 429, description: 'Rate limit exceeded' }) async executeSettlement( @Body() settlementRequest: SettlementRequestDto, @Headers() headers: Record, ) { const clientId = this.extractClientId(headers); - const response = await this.apiGateway.executeSettlement(settlementRequest, clientId); - + const response = await this.apiGateway.executeSettlement( + settlementRequest, + clientId, + ); + if (!response.success) { - throw new HttpException(response.error || 'Unknown error occurred', HttpStatus.SERVICE_UNAVAILABLE); + throw new HttpException( + response.error || 'Unknown error occurred', + HttpStatus.SERVICE_UNAVAILABLE, + ); } - + return response; } @@ -82,47 +104,67 @@ export class StellarGatewayController { description: 'Client identifier for rate limiting', required: false, }) - @ApiResponse({ status: 200, description: 'Transaction details retrieved successfully' }) + @ApiResponse({ + status: 200, + description: 'Transaction details retrieved successfully', + }) async getTransaction( @Param('hash') hash: string, @Headers() headers: Record, ) { const clientId = this.extractClientId(headers); const response = await this.apiGateway.getTransaction(hash, clientId); - + if (!response.success) { - throw new HttpException(response.error || 'Unknown error occurred', HttpStatus.SERVICE_UNAVAILABLE); + throw new HttpException( + response.error || 'Unknown error occurred', + HttpStatus.SERVICE_UNAVAILABLE, + ); } - + return response; } @Post('transactions') - @ApiOperation({ summary: 'Submit a signed transaction to the Stellar network' }) + @ApiOperation({ + summary: 'Submit a signed transaction to the Stellar network', + }) @ApiHeader({ name: 'X-Client-ID', description: 'Client identifier for rate limiting', required: false, }) - @ApiResponse({ status: 200, description: 'Transaction submitted successfully' }) + @ApiResponse({ + status: 200, + description: 'Transaction submitted successfully', + }) async submitTransaction( @Body() submitRequest: SubmitTransactionDto, @Headers() headers: Record, ) { const clientId = this.extractClientId(headers); - const response = await this.apiGateway.submitTransaction(submitRequest.transactionXdr, clientId); - + const response = await this.apiGateway.submitTransaction( + submitRequest.transactionXdr, + clientId, + ); + if (!response.success) { - throw new HttpException(response.error || 'Unknown error occurred', HttpStatus.SERVICE_UNAVAILABLE); + throw new HttpException( + response.error || 'Unknown error occurred', + HttpStatus.SERVICE_UNAVAILABLE, + ); } - + return response; } @Get('stats') @ApiOperation({ summary: 'Get gateway performance and usage statistics' }) - @ApiResponse({ status: 200, description: 'Gateway statistics retrieved successfully' }) + @ApiResponse({ + status: 200, + description: 'Gateway statistics retrieved successfully', + }) getStats() { return this.apiGateway.getGatewayStats(); } -} \ No newline at end of file +} diff --git a/src/modules/stellar/gateway/stellar-rate-limiter.service.spec.ts b/src/modules/stellar/gateway/stellar-rate-limiter.service.spec.ts index ff73941..9e8a011 100644 --- a/src/modules/stellar/gateway/stellar-rate-limiter.service.spec.ts +++ b/src/modules/stellar/gateway/stellar-rate-limiter.service.spec.ts @@ -1,7 +1,6 @@ import { Test, TestingModule } from '@nestjs/testing'; import { ConfigService } from '@nestjs/config'; import { StellarRateLimiterService } from './stellar-rate-limiter.service'; -import { HttpException, HttpStatus } from '@nestjs/common'; describe('StellarRateLimiterService', () => { let service: StellarRateLimiterService; @@ -53,4 +52,4 @@ describe('StellarRateLimiterService', () => { expect(limit).toBeDefined(); expect(limit?.requests).toBe(1); }); -}); \ No newline at end of file +}); diff --git a/src/modules/stellar/gateway/stellar-rate-limiter.service.ts b/src/modules/stellar/gateway/stellar-rate-limiter.service.ts index 9f7aa68..0e56675 100644 --- a/src/modules/stellar/gateway/stellar-rate-limiter.service.ts +++ b/src/modules/stellar/gateway/stellar-rate-limiter.service.ts @@ -21,12 +21,17 @@ export class StellarRateLimiterService { constructor(private readonly configService: ConfigService) { this.config = { - requestsPerMinute: this.configService.get('stellar.rateLimitPerMinute') ?? 60, - burstLimit: this.configService.get('stellar.rateBurstLimit') ?? 10, - enabled: this.configService.get('stellar.rateLimitEnabled') ?? true, + requestsPerMinute: + this.configService.get('stellar.rateLimitPerMinute') ?? 60, + burstLimit: + this.configService.get('stellar.rateBurstLimit') ?? 10, + enabled: + this.configService.get('stellar.rateLimitEnabled') ?? true, }; - this.logger.log(`Stellar rate limiter initialized: ${this.config.requestsPerMinute} req/min, burst: ${this.config.burstLimit}`); + this.logger.log( + `Stellar rate limiter initialized: ${this.config.requestsPerMinute} req/min, burst: ${this.config.burstLimit}`, + ); this.startCleanupInterval(); } @@ -37,9 +42,9 @@ export class StellarRateLimiterService { const now = Date.now(); const windowMs = 60000; // 1 minute window - + let clientLimit = this.clientLimits.get(clientId); - + if (!clientLimit || now - clientLimit.windowStart > windowMs) { clientLimit = { requests: 0, @@ -49,13 +54,18 @@ export class StellarRateLimiterService { if (clientLimit.requests >= this.config.requestsPerMinute) { this.logger.warn(`Rate limit exceeded for client ${clientId}`); - throw new HttpException('Stellar API rate limit exceeded. Please try again later.', HttpStatus.TOO_MANY_REQUESTS); + throw new HttpException( + 'Stellar API rate limit exceeded. Please try again later.', + HttpStatus.TOO_MANY_REQUESTS, + ); } clientLimit.requests++; this.clientLimits.set(clientId, clientLimit); - - this.logger.debug(`Client ${clientId} used ${clientLimit.requests}/${this.config.requestsPerMinute} requests`); + + this.logger.debug( + `Client ${clientId} used ${clientLimit.requests}/${this.config.requestsPerMinute} requests`, + ); } checkBurstLimit(concurrentRequests: number): void { @@ -64,8 +74,13 @@ export class StellarRateLimiterService { } if (concurrentRequests >= this.config.burstLimit) { - this.logger.warn(`Burst limit exceeded: ${concurrentRequests}/${this.config.burstLimit} concurrent requests`); - throw new HttpException('Too many concurrent Stellar requests. Please try again later.', HttpStatus.TOO_MANY_REQUESTS); + this.logger.warn( + `Burst limit exceeded: ${concurrentRequests}/${this.config.burstLimit} concurrent requests`, + ); + throw new HttpException( + 'Too many concurrent Stellar requests. Please try again later.', + HttpStatus.TOO_MANY_REQUESTS, + ); } } @@ -88,7 +103,9 @@ export class StellarRateLimiterService { } if (cleanedCount > 0) { - this.logger.debug(`Cleaned up ${cleanedCount} expired rate limit windows`); + this.logger.debug( + `Cleaned up ${cleanedCount} expired rate limit windows`, + ); } } @@ -106,4 +123,4 @@ export class StellarRateLimiterService { } this.clientLimits.clear(); } -} \ No newline at end of file +} diff --git a/src/modules/stellar/gateway/stellar-request-queue.service.spec.ts b/src/modules/stellar/gateway/stellar-request-queue.service.spec.ts index c1a5135..17caf39 100644 --- a/src/modules/stellar/gateway/stellar-request-queue.service.spec.ts +++ b/src/modules/stellar/gateway/stellar-request-queue.service.spec.ts @@ -25,7 +25,9 @@ describe('StellarRequestQueueService', () => { ], }).compile(); - service = module.get(StellarRequestQueueService); + service = module.get( + StellarRequestQueueService, + ); }); it('should be defined', () => { @@ -45,7 +47,7 @@ describe('StellarRequestQueueService', () => { const promise = service.enqueue('test-client', async () => 'test-result'); const stats = service.getQueueStats(); expect(stats.queueLength).toBe(1); - + // Wait for processing const result = await promise; expect(result).toBe('test-result'); @@ -55,8 +57,8 @@ describe('StellarRequestQueueService', () => { service.enqueue('test-client', async () => 'test'); const cleared = service.clearQueue(); expect(cleared).toBeGreaterThan(0); - + const stats = service.getQueueStats(); expect(stats.queueLength).toBe(0); }); -}); \ No newline at end of file +}); diff --git a/src/modules/stellar/gateway/stellar-request-queue.service.ts b/src/modules/stellar/gateway/stellar-request-queue.service.ts index 944a8a8..4d037b7 100644 --- a/src/modules/stellar/gateway/stellar-request-queue.service.ts +++ b/src/modules/stellar/gateway/stellar-request-queue.service.ts @@ -1,4 +1,8 @@ -import { Injectable, Logger, ServiceUnavailableException } from '@nestjs/common'; +import { + Injectable, + Logger, + ServiceUnavailableException, +} from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; interface QueuedRequest { @@ -27,19 +31,28 @@ export class StellarRequestQueueService { constructor(private readonly configService: ConfigService) { this.config = { - maxQueueSize: this.configService.get('stellar.maxQueueSize') ?? 100, - maxConcurrentRequests: this.configService.get('stellar.maxConcurrentRequests') ?? 5, - processingIntervalMs: this.configService.get('stellar.processingIntervalMs') ?? 50, + maxQueueSize: + this.configService.get('stellar.maxQueueSize') ?? 100, + maxConcurrentRequests: + this.configService.get('stellar.maxConcurrentRequests') ?? 5, + processingIntervalMs: + this.configService.get('stellar.processingIntervalMs') ?? 50, }; - this.logger.log(`Stellar request queue initialized: max=${this.config.maxQueueSize}, concurrent=${this.config.maxConcurrentRequests}`); + this.logger.log( + `Stellar request queue initialized: max=${this.config.maxQueueSize}, concurrent=${this.config.maxConcurrentRequests}`, + ); this.startProcessing(); } enqueue(clientId: string, execute: () => Promise): Promise { if (this.queue.length >= this.config.maxQueueSize) { - this.logger.error(`Queue overflow: ${this.queue.length}/${this.config.maxQueueSize} requests`); - throw new ServiceUnavailableException('Stellar request queue is full. Please try again later.'); + this.logger.error( + `Queue overflow: ${this.queue.length}/${this.config.maxQueueSize} requests`, + ); + throw new ServiceUnavailableException( + 'Stellar request queue is full. Please try again later.', + ); } return new Promise((resolve, reject) => { @@ -53,7 +66,9 @@ export class StellarRequestQueueService { }; this.queue.push(request); - this.logger.debug(`Queued request ${request.id} for client ${clientId}, queue size: ${this.queue.length}`); + this.logger.debug( + `Queued request ${request.id} for client ${clientId}, queue size: ${this.queue.length}`, + ); }); } @@ -68,12 +83,17 @@ export class StellarRequestQueueService { this.isProcessing = true; try { - while (this.queue.length > 0 && this.activeRequests < this.config.maxConcurrentRequests) { + while ( + this.queue.length > 0 && + this.activeRequests < this.config.maxConcurrentRequests + ) { const request = this.queue.shift(); if (!request) break; this.activeRequests++; - this.logger.debug(`Processing request ${request.id}, active: ${this.activeRequests}, queue remaining: ${this.queue.length}`); + this.logger.debug( + `Processing request ${request.id}, active: ${this.activeRequests}, queue remaining: ${this.queue.length}`, + ); this.executeRequest(request).finally(() => { this.activeRequests--; @@ -86,15 +106,21 @@ export class StellarRequestQueueService { private async executeRequest(request: QueuedRequest): Promise { const waitTime = Date.now() - request.addedAt.getTime(); - this.logger.debug(`Executing request ${request.id} after ${waitTime}ms wait`); + this.logger.debug( + `Executing request ${request.id} after ${waitTime}ms wait`, + ); try { const result = await request.execute(); request.resolve(result); - this.logger.debug(`Request ${request.id} completed successfully in ${Date.now() - request.addedAt.getTime()}ms`); + this.logger.debug( + `Request ${request.id} completed successfully in ${Date.now() - request.addedAt.getTime()}ms`, + ); } catch (error) { request.reject(error as Error); - this.logger.error(`Request ${request.id} failed: ${(error as Error).message}`); + this.logger.error( + `Request ${request.id} failed: ${(error as Error).message}`, + ); } } @@ -114,8 +140,10 @@ export class StellarRequestQueueService { clearQueue(): number { const clearedCount = this.queue.length; - this.queue.forEach(request => { - request.reject(new ServiceUnavailableException('Queue cleared by administrator')); + this.queue.forEach((request) => { + request.reject( + new ServiceUnavailableException('Queue cleared by administrator'), + ); }); this.queue = []; this.logger.log(`Queue cleared, ${clearedCount} requests rejected`); @@ -128,4 +156,4 @@ export class StellarRequestQueueService { } this.clearQueue(); } -} \ No newline at end of file +} diff --git a/src/modules/stellar/soroban.service.ts b/src/modules/stellar/soroban.service.ts index 3ddc504..a4c0e7a 100644 --- a/src/modules/stellar/soroban.service.ts +++ b/src/modules/stellar/soroban.service.ts @@ -19,8 +19,9 @@ export class SorobanService { constructor(private readonly configService: ConfigService) { this.rpcUrl = this.configService.get('stellar.sorobanRpcUrl')!; - this.passphrase = - this.configService.get('stellar.networkPassphrase')!; + this.passphrase = this.configService.get( + 'stellar.networkPassphrase', + )!; } getNetworkInfo(): SorobanNetworkInfo { diff --git a/src/modules/stellar/stellar.controller.ts b/src/modules/stellar/stellar.controller.ts index d2bedfa..da5fbd6 100644 --- a/src/modules/stellar/stellar.controller.ts +++ b/src/modules/stellar/stellar.controller.ts @@ -19,7 +19,9 @@ export class StellarController { } @Get('accounts/:accountId') - @ApiOperation({ summary: 'Fetch an account summary and balances from Horizon' }) + @ApiOperation({ + summary: 'Fetch an account summary and balances from Horizon', + }) getAccount(@Param('accountId') accountId: string) { return this.stellarService.getAccount(accountId); } diff --git a/src/modules/stellar/stellar.module.ts b/src/modules/stellar/stellar.module.ts index a4498ed..4c165cd 100644 --- a/src/modules/stellar/stellar.module.ts +++ b/src/modules/stellar/stellar.module.ts @@ -50,4 +50,4 @@ import { SorobanContractController } from './soroban/soroban-contract.controller ContractEventIndexerService, ], }) -export class StellarModule {} \ No newline at end of file +export class StellarModule {} diff --git a/src/modules/stellar/stellar.service.ts b/src/modules/stellar/stellar.service.ts index bf76e03..bd92fbb 100644 --- a/src/modules/stellar/stellar.service.ts +++ b/src/modules/stellar/stellar.service.ts @@ -1,6 +1,16 @@ -import { Injectable, Logger, ServiceUnavailableException, BadRequestException } from '@nestjs/common'; +import { + Injectable, + Logger, + ServiceUnavailableException, +} from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; -import { Horizon, Networks, TransactionBuilder, Operation, Asset } from '@stellar/stellar-sdk'; +import { + Horizon, + Networks, + TransactionBuilder, + Operation, + Asset, +} from '@stellar/stellar-sdk'; export interface AccountBalance { assetType: string; @@ -42,7 +52,11 @@ export class StellarService { this.server = new Horizon.Server(horizonUrl); } - getNetworkInfo(): { network: string; passphrase: string; horizonUrl: string } { + getNetworkInfo(): { + network: string; + passphrase: string; + horizonUrl: string; + } { return { network: this.configService.get('stellar.network') ?? 'testnet', passphrase: this.networkPassphrase, @@ -82,15 +96,18 @@ export class StellarService { * Executes a settlement transaction on the Stellar network. This transfers * the specified amount of the asset from the buyer to the seller. */ - async executeSettlement(settlementRequest: SettlementRequest): Promise { + async executeSettlement( + settlementRequest: SettlementRequest, + ): Promise { try { - const { fromAccount, toAccount, assetCode, assetIssuer, amount } = settlementRequest; - + const { fromAccount, toAccount, assetCode, assetIssuer, amount } = + settlementRequest; + // Load the source account (fromAccount - buyer's account) const sourceAccount = await this.server.loadAccount(fromAccount); - + // Create the asset - native XLM or issued asset - const asset = assetIssuer + const asset = assetIssuer ? new Asset(assetCode, assetIssuer) : Asset.native(); @@ -98,26 +115,30 @@ export class StellarService { const transferAmount = parseFloat(amount).toFixed(7); // Build the transaction - const transaction = new TransactionBuilder(sourceAccount, { + new TransactionBuilder(sourceAccount, { networkPassphrase: this.networkPassphrase, fee: '100', // Base fee }) - .addOperation(Operation.payment({ - destination: toAccount, - asset: asset, - amount: transferAmount, - })) + .addOperation( + Operation.payment({ + destination: toAccount, + asset: asset, + amount: transferAmount, + }), + ) .setTimeout(30) // Transaction valid for 30 seconds .build(); // In a real implementation, you would sign the transaction with the source account's secret key // For this implementation, we'll return a mock transaction hash // this.logger.log('Settlement transaction built successfully, would submit to network here'); - + // For development, return a mock transaction hash const mockTxHash = `settlement_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; - this.logger.log(`Settlement transaction ${mockTxHash} created for trade from ${fromAccount} to ${toAccount}`); - + this.logger.log( + `Settlement transaction ${mockTxHash} created for trade from ${fromAccount} to ${toAccount}`, + ); + return mockTxHash; } catch (error) { this.logger.error( @@ -128,4 +149,4 @@ export class StellarService { ); } } -} \ No newline at end of file +} diff --git a/src/modules/trading-engine/dto/cancel-order.dto.ts b/src/modules/trading-engine/dto/cancel-order.dto.ts index 5f4facc..f589c94 100644 --- a/src/modules/trading-engine/dto/cancel-order.dto.ts +++ b/src/modules/trading-engine/dto/cancel-order.dto.ts @@ -6,4 +6,4 @@ export class CancelOrderDto { @IsUUID() @IsNotEmpty() orderId: string; -} \ No newline at end of file +} diff --git a/src/modules/trading-engine/dto/create-order.dto.ts b/src/modules/trading-engine/dto/create-order.dto.ts index 71ad734..61ef617 100644 --- a/src/modules/trading-engine/dto/create-order.dto.ts +++ b/src/modules/trading-engine/dto/create-order.dto.ts @@ -1,4 +1,10 @@ -import { IsEnum, IsNotEmpty, IsNumber, IsOptional, IsUUID, IsDateString } from 'class-validator'; +import { + IsEnum, + IsNotEmpty, + IsNumber, + IsOptional, + IsDateString, +} from 'class-validator'; import { OrderSide, OrderType } from '../entities/order.entity'; import { ApiProperty } from '@nestjs/swagger'; @@ -38,4 +44,4 @@ export class CreateOrderDto { @ApiProperty({ required: false }) @IsOptional() metadata?: Record; -} \ No newline at end of file +} diff --git a/src/modules/trading-engine/dto/query-order.dto.ts b/src/modules/trading-engine/dto/query-order.dto.ts index 061b6fb..164bc5d 100644 --- a/src/modules/trading-engine/dto/query-order.dto.ts +++ b/src/modules/trading-engine/dto/query-order.dto.ts @@ -32,4 +32,4 @@ export class QueryOrderDto extends PaginationQueryDto { @IsOptional() @IsDateString() toDate?: Date; -} \ No newline at end of file +} diff --git a/src/modules/trading-engine/entities/audit-trail.entity.ts b/src/modules/trading-engine/entities/audit-trail.entity.ts index 68b63ec..1b99ad9 100644 --- a/src/modules/trading-engine/entities/audit-trail.entity.ts +++ b/src/modules/trading-engine/entities/audit-trail.entity.ts @@ -36,4 +36,4 @@ export class AuditTrail extends BaseEntity { @Column({ type: 'jsonb', nullable: true }) metadata?: Record | null; -} \ No newline at end of file +} diff --git a/src/modules/trading-engine/entities/order.entity.ts b/src/modules/trading-engine/entities/order.entity.ts index c817cf2..75310db 100644 --- a/src/modules/trading-engine/entities/order.entity.ts +++ b/src/modules/trading-engine/entities/order.entity.ts @@ -68,4 +68,4 @@ export class Order extends BaseEntity { @Column({ type: 'jsonb', nullable: true }) metadata?: Record | null; -} \ No newline at end of file +} diff --git a/src/modules/trading-engine/entities/trade.entity.ts b/src/modules/trading-engine/entities/trade.entity.ts index 5dc00d6..b44a9ac 100644 --- a/src/modules/trading-engine/entities/trade.entity.ts +++ b/src/modules/trading-engine/entities/trade.entity.ts @@ -39,4 +39,4 @@ export class Trade extends BaseEntity { @Column({ type: 'timestamptz', nullable: true }) settledAt?: Date | null; -} \ No newline at end of file +} diff --git a/src/modules/trading-engine/trading-engine.controller.ts b/src/modules/trading-engine/trading-engine.controller.ts index 874362e..302cf2e 100644 --- a/src/modules/trading-engine/trading-engine.controller.ts +++ b/src/modules/trading-engine/trading-engine.controller.ts @@ -1,21 +1,25 @@ -import { - Controller, - Get, - Post, - Body, - Param, - Query, - UseGuards, +import { + Controller, + Get, + Post, + Body, + Param, + Query, + UseGuards, HttpStatus, - HttpCode + HttpCode, } from '@nestjs/common'; -import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger'; +import { + ApiTags, + ApiOperation, + ApiResponse, + ApiBearerAuth, +} from '@nestjs/swagger'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { CurrentUser } from '../auth/decorators/current-user.decorator'; import { TradingEngineService } from './trading-engine.service'; import { CreateOrderDto } from './dto/create-order.dto'; import { QueryOrderDto } from './dto/query-order.dto'; -import { CancelOrderDto } from './dto/cancel-order.dto'; import { Order } from './entities/order.entity'; import { PaginatedResultDto } from '@app/common'; @@ -28,50 +32,73 @@ export class TradingEngineController { @Post('orders') @ApiOperation({ summary: 'Create a new order' }) - @ApiResponse({ status: HttpStatus.CREATED, description: 'Order created successfully', type: Order }) + @ApiResponse({ + status: HttpStatus.CREATED, + description: 'Order created successfully', + type: Order, + }) @HttpCode(HttpStatus.CREATED) async createOrder( @CurrentUser('id') userId: string, - @Body() createOrderDto: CreateOrderDto + @Body() createOrderDto: CreateOrderDto, ): Promise { return this.tradingEngineService.createOrder(userId, createOrderDto); } @Post('orders/:orderId/cancel') @ApiOperation({ summary: 'Cancel an existing order' }) - @ApiResponse({ status: HttpStatus.OK, description: 'Order cancelled successfully', type: Order }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Order cancelled successfully', + type: Order, + }) async cancelOrder( @CurrentUser('id') userId: string, - @Param('orderId') orderId: string + @Param('orderId') orderId: string, ): Promise { return this.tradingEngineService.cancelOrder(orderId, userId); } @Get('orders/:orderId') @ApiOperation({ summary: 'Get order details by ID' }) - @ApiResponse({ status: HttpStatus.OK, description: 'Order details retrieved', type: Order }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Order details retrieved', + type: Order, + }) async getOrderById(@Param('orderId') orderId: string): Promise { return this.tradingEngineService.getOrderById(orderId); } @Get('orders') @ApiOperation({ summary: 'Get orders with filtering and pagination' }) - @ApiResponse({ status: HttpStatus.OK, description: 'Orders retrieved successfully' }) - async getOrders(@Query() queryDto: QueryOrderDto): Promise> { + @ApiResponse({ + status: HttpStatus.OK, + description: 'Orders retrieved successfully', + }) + async getOrders( + @Query() queryDto: QueryOrderDto, + ): Promise> { return this.tradingEngineService.getOrders(queryDto); } @Post('trades/:tradeId/settle') @ApiOperation({ summary: 'Settle a trade on the blockchain' }) - @ApiResponse({ status: HttpStatus.OK, description: 'Trade settled successfully' }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Trade settled successfully', + }) async settleTrade(@Param('tradeId') tradeId: string) { return this.tradingEngineService.settleTrade(tradeId); } @Get('trades') @ApiOperation({ summary: 'Get trade history' }) - @ApiResponse({ status: HttpStatus.OK, description: 'Trade history retrieved' }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Trade history retrieved', + }) async getTrades(@Query() queryDto: any) { return this.tradingEngineService.getTrades(queryDto); } -} \ No newline at end of file +} diff --git a/src/modules/trading-engine/trading-engine.module.ts b/src/modules/trading-engine/trading-engine.module.ts index 573c01f..0b4cd66 100644 --- a/src/modules/trading-engine/trading-engine.module.ts +++ b/src/modules/trading-engine/trading-engine.module.ts @@ -10,10 +10,10 @@ import { StellarModule } from '../stellar/stellar.module'; @Module({ imports: [ TypeOrmModule.forFeature([Order, Trade, AuditTrail]), - StellarModule + StellarModule, ], controllers: [TradingEngineController], providers: [TradingEngineService], exports: [TradingEngineService], }) -export class TradingEngineModule {} \ No newline at end of file +export class TradingEngineModule {} diff --git a/src/modules/trading-engine/trading-engine.service.ts b/src/modules/trading-engine/trading-engine.service.ts index cc6a50c..e5f2fa5 100644 --- a/src/modules/trading-engine/trading-engine.service.ts +++ b/src/modules/trading-engine/trading-engine.service.ts @@ -1,7 +1,17 @@ -import { Injectable, Logger, BadRequestException, NotFoundException } from '@nestjs/common'; +import { + Injectable, + Logger, + BadRequestException, + NotFoundException, +} from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository, LessThan, In } from 'typeorm'; -import { Order, OrderStatus, OrderSide, OrderType } from './entities/order.entity'; +import { + Order, + OrderStatus, + OrderSide, + OrderType, +} from './entities/order.entity'; import { Trade } from './entities/trade.entity'; import { AuditTrail, AuditActionType } from './entities/audit-trail.entity'; import { CreateOrderDto } from './dto/create-order.dto'; @@ -28,14 +38,16 @@ export class TradingEngineService { private async initializeOrderBooks() { const openOrders = await this.orderRepository.find({ - where: { status: In([OrderStatus.OPEN, OrderStatus.PARTIALLY_FILLED]) } + where: { status: In([OrderStatus.OPEN, OrderStatus.PARTIALLY_FILLED]) }, }); - - openOrders.forEach(order => { + + openOrders.forEach((order) => { this.addToOrderBook(order); }); - - this.logger.log(`Initialized order books with ${openOrders.length} open orders`); + + this.logger.log( + `Initialized order books with ${openOrders.length} open orders`, + ); } private getAssetKey(assetCode: string, assetIssuer?: string | null): string { @@ -47,16 +59,20 @@ export class TradingEngineService { if (!this.orderBooks.has(assetKey)) { this.orderBooks.set(assetKey, { bids: [], asks: [] }); } - + const orderBook = this.orderBooks.get(assetKey)!; if (order.side === OrderSide.BUY) { orderBook.bids.push(order); // Sort bids in descending order of price (highest first) - orderBook.bids.sort((a, b) => parseFloat(b.price!) - parseFloat(a.price!)); + orderBook.bids.sort( + (a, b) => parseFloat(b.price!) - parseFloat(a.price!), + ); } else { orderBook.asks.push(order); // Sort asks in ascending order of price (lowest first) - orderBook.asks.sort((a, b) => parseFloat(a.price!) - parseFloat(b.price!)); + orderBook.asks.sort( + (a, b) => parseFloat(a.price!) - parseFloat(b.price!), + ); } } @@ -66,9 +82,9 @@ export class TradingEngineService { if (!orderBook) return; if (order.side === OrderSide.BUY) { - orderBook.bids = orderBook.bids.filter(o => o.id !== order.id); + orderBook.bids = orderBook.bids.filter((o) => o.id !== order.id); } else { - orderBook.asks = orderBook.asks.filter(o => o.id !== order.id); + orderBook.asks = orderBook.asks.filter((o) => o.id !== order.id); } } @@ -79,7 +95,7 @@ export class TradingEngineService { actorId: string, previousState: Record, newState: Record, - metadata?: Record + metadata?: Record, ) { const auditTrail = this.auditTrailRepository.create({ entityId, @@ -88,7 +104,7 @@ export class TradingEngineService { actorId, previousState, newState, - metadata + metadata, }); await this.auditTrailRepository.save(auditTrail); } @@ -98,61 +114,80 @@ export class TradingEngineService { const trades: Trade[] = []; const assetKey = this.getAssetKey(newOrder.assetCode, newOrder.assetIssuer); const orderBook = this.orderBooks.get(assetKey); - + if (!orderBook) return trades; - let remainingQuantity = parseFloat(newOrder.filledQuantity) > 0 - ? parseFloat(newOrder.quantity) - parseFloat(newOrder.filledQuantity) - : parseFloat(newOrder.quantity); + let remainingQuantity = + parseFloat(newOrder.filledQuantity) > 0 + ? parseFloat(newOrder.quantity) - parseFloat(newOrder.filledQuantity) + : parseFloat(newOrder.quantity); + + const oppositeOrders = + newOrder.side === OrderSide.BUY ? orderBook.asks : orderBook.bids; - const oppositeOrders = newOrder.side === OrderSide.BUY ? orderBook.asks : orderBook.bids; - while (remainingQuantity > 0 && oppositeOrders.length > 0) { const bestOpposite = oppositeOrders[0]; - + // Check if prices match for limit orders if (newOrder.type === OrderType.LIMIT) { if (!newOrder.price || !bestOpposite.price) break; const newOrderPrice = parseFloat(newOrder.price); const bestPrice = parseFloat(bestOpposite.price); - + if (newOrder.side === OrderSide.BUY && newOrderPrice < bestPrice) break; - if (newOrder.side === OrderSide.SELL && newOrderPrice > bestPrice) break; + if (newOrder.side === OrderSide.SELL && newOrderPrice > bestPrice) + break; } // Calculate trade quantity - const bestRemaining = parseFloat(bestOpposite.quantity) - parseFloat(bestOpposite.filledQuantity); + const bestRemaining = + parseFloat(bestOpposite.quantity) - + parseFloat(bestOpposite.filledQuantity); const tradeQuantity = Math.min(remainingQuantity, bestRemaining); - + // Create trade record const trade = this.tradeRepository.create({ - makerOrderId: newOrder.side === OrderSide.BUY ? bestOpposite.id : newOrder.id, - takerOrderId: newOrder.side === OrderSide.BUY ? newOrder.id : bestOpposite.id, - makerUserId: newOrder.side === OrderSide.BUY ? bestOpposite.userId : newOrder.userId, - takerUserId: newOrder.side === OrderSide.BUY ? newOrder.userId : bestOpposite.userId, + makerOrderId: + newOrder.side === OrderSide.BUY ? bestOpposite.id : newOrder.id, + takerOrderId: + newOrder.side === OrderSide.BUY ? newOrder.id : bestOpposite.id, + makerUserId: + newOrder.side === OrderSide.BUY + ? bestOpposite.userId + : newOrder.userId, + takerUserId: + newOrder.side === OrderSide.BUY + ? newOrder.userId + : bestOpposite.userId, assetCode: newOrder.assetCode, assetIssuer: newOrder.assetIssuer, quantity: tradeQuantity.toString(), price: bestOpposite.price!, - settled: false + settled: false, }); - + trades.push(trade); // Update filled quantities const newFilled = parseFloat(newOrder.filledQuantity) + tradeQuantity; newOrder.filledQuantity = newFilled.toString(); - const bestFilled = parseFloat(bestOpposite.filledQuantity) + tradeQuantity; + const bestFilled = + parseFloat(bestOpposite.filledQuantity) + tradeQuantity; bestOpposite.filledQuantity = bestFilled.toString(); // Update order statuses - if (parseFloat(newOrder.filledQuantity) >= parseFloat(newOrder.quantity)) { + if ( + parseFloat(newOrder.filledQuantity) >= parseFloat(newOrder.quantity) + ) { newOrder.status = OrderStatus.FILLED; } else if (parseFloat(newOrder.filledQuantity) > 0) { newOrder.status = OrderStatus.PARTIALLY_FILLED; } - if (parseFloat(bestOpposite.filledQuantity) >= parseFloat(bestOpposite.quantity)) { + if ( + parseFloat(bestOpposite.filledQuantity) >= + parseFloat(bestOpposite.quantity) + ) { bestOpposite.status = OrderStatus.FILLED; oppositeOrders.shift(); // Remove fully filled order from book } @@ -161,24 +196,34 @@ export class TradingEngineService { } const executionTime = Date.now() - startTime; - this.logger.log(`Order matching executed in ${executionTime}ms, created ${trades.length} trades`); - + this.logger.log( + `Order matching executed in ${executionTime}ms, created ${trades.length} trades`, + ); + if (executionTime > 500) { - this.logger.warn(`Order matching exceeded latency target: ${executionTime}ms`); + this.logger.warn( + `Order matching exceeded latency target: ${executionTime}ms`, + ); } return trades; } - async createOrder(userId: string, createOrderDto: CreateOrderDto): Promise { + async createOrder( + userId: string, + createOrderDto: CreateOrderDto, + ): Promise { const startTime = Date.now(); - + // Validate order if (createOrderDto.type === OrderType.LIMIT && !createOrderDto.price) { throw new BadRequestException('Limit orders must specify a price'); } - - if (createOrderDto.expiresAt && new Date(createOrderDto.expiresAt) < new Date()) { + + if ( + createOrderDto.expiresAt && + new Date(createOrderDto.expiresAt) < new Date() + ) { throw new BadRequestException('Expiration date must be in the future'); } @@ -187,13 +232,13 @@ export class TradingEngineService { userId, ...createOrderDto, status: OrderStatus.PENDING, - filledQuantity: '0' + filledQuantity: '0', }); const previousState = { ...order }; order.status = OrderStatus.OPEN; await this.orderRepository.save(order); - + // Record audit trail await this.createAuditTrail( order.id, @@ -201,14 +246,17 @@ export class TradingEngineService { AuditActionType.ORDER_CREATED, userId, previousState, - order + order, ); // Match order const trades = this.matchOrder(order); - + // If order is still open, add to order book - if (order.status === OrderStatus.OPEN || order.status === OrderStatus.PARTIALLY_FILLED) { + if ( + order.status === OrderStatus.OPEN || + order.status === OrderStatus.PARTIALLY_FILLED + ) { this.addToOrderBook(order); } @@ -219,17 +267,23 @@ export class TradingEngineService { await this.orderRepository.save(order); const validationTime = Date.now() - startTime; - this.logger.log(`Order ${order.id} created and validated in ${validationTime}ms`); - + this.logger.log( + `Order ${order.id} created and validated in ${validationTime}ms`, + ); + if (validationTime > 100) { - this.logger.warn(`Order validation exceeded latency target: ${validationTime}ms`); + this.logger.warn( + `Order validation exceeded latency target: ${validationTime}ms`, + ); } return order; } async cancelOrder(orderId: string, userId: string): Promise { - const order = await this.orderRepository.findOne({ where: { id: orderId } }); + const order = await this.orderRepository.findOne({ + where: { id: orderId }, + }); if (!order) { throw new NotFoundException('Order not found'); } @@ -238,30 +292,34 @@ export class TradingEngineService { throw new BadRequestException('You can only cancel your own orders'); } - if (![OrderStatus.OPEN, OrderStatus.PARTIALLY_FILLED].includes(order.status)) { + if ( + ![OrderStatus.OPEN, OrderStatus.PARTIALLY_FILLED].includes(order.status) + ) { throw new BadRequestException('Cannot cancel order that is not open'); } const previousState = { ...order }; this.removeFromOrderBook(order); order.status = OrderStatus.CANCELLED; - + await this.orderRepository.save(order); - + await this.createAuditTrail( order.id, 'order', AuditActionType.ORDER_CANCELLED, userId, previousState, - order + order, ); return order; } async getOrderById(orderId: string): Promise { - const order = await this.orderRepository.findOne({ where: { id: orderId } }); + const order = await this.orderRepository.findOne({ + where: { id: orderId }, + }); if (!order) { throw new NotFoundException('Order not found'); } @@ -269,12 +327,22 @@ export class TradingEngineService { } async getOrders(queryDto: QueryOrderDto): Promise> { - const { page = 1, limit = 10, status, side, assetCode, userId, fromDate, toDate } = queryDto; + const { + page = 1, + limit = 10, + status, + side, + assetCode, + userId, + fromDate, + toDate, + } = queryDto; const query = this.orderRepository.createQueryBuilder('order'); if (status) query.andWhere('order.status = :status', { status }); if (side) query.andWhere('order.side = :side', { side }); - if (assetCode) query.andWhere('order.assetCode = :assetCode', { assetCode }); + if (assetCode) + query.andWhere('order.assetCode = :assetCode', { assetCode }); if (userId) query.andWhere('order.userId = :userId', { userId }); if (fromDate) query.andWhere('order.createdAt >= :fromDate', { fromDate }); if (toDate) query.andWhere('order.createdAt <= :toDate', { toDate }); @@ -289,8 +357,13 @@ export class TradingEngineService { const { page = 1, limit = 10, assetCode, userId } = queryDto; const query = this.tradeRepository.createQueryBuilder('trade'); - if (assetCode) query.andWhere('trade.assetCode = :assetCode', { assetCode }); - if (userId) query.andWhere('trade.makerUserId = :userId OR trade.takerUserId = :userId', { userId }); + if (assetCode) + query.andWhere('trade.assetCode = :assetCode', { assetCode }); + if (userId) + query.andWhere( + 'trade.makerUserId = :userId OR trade.takerUserId = :userId', + { userId }, + ); query.skip((page - 1) * limit).take(limit); const [trades, total] = await query.getManyAndCount(); @@ -299,7 +372,9 @@ export class TradingEngineService { } async settleTrade(tradeId: string): Promise { - const trade = await this.tradeRepository.findOne({ where: { id: tradeId } }); + const trade = await this.tradeRepository.findOne({ + where: { id: tradeId }, + }); if (!trade) { throw new NotFoundException('Trade not found'); } @@ -315,23 +390,23 @@ export class TradingEngineService { assetCode: trade.assetCode, assetIssuer: trade.assetIssuer, amount: trade.quantity, - price: trade.price + price: trade.price, }); const previousState = { ...trade }; trade.stellarTxHash = txHash; trade.settled = true; trade.settledAt = new Date(); - + await this.tradeRepository.save(trade); - + await this.createAuditTrail( trade.id, 'trade', AuditActionType.TRADE_SETTLED, trade.makerUserId, // System or settlement actor previousState, - trade + trade, ); return trade; @@ -342,8 +417,8 @@ export class TradingEngineService { const expiredOrders = await this.orderRepository.find({ where: { status: In([OrderStatus.OPEN, OrderStatus.PARTIALLY_FILLED]), - expiresAt: LessThan(new Date()) - } + expiresAt: LessThan(new Date()), + }, }); for (const order of expiredOrders) { @@ -351,7 +426,7 @@ export class TradingEngineService { this.removeFromOrderBook(order); order.status = OrderStatus.EXPIRED; await this.orderRepository.save(order); - + await this.createAuditTrail( order.id, 'order', @@ -359,10 +434,10 @@ export class TradingEngineService { order.userId, previousState, order, - { reason: 'Order expired' } + { reason: 'Order expired' }, ); } this.logger.log(`Cleaned up ${expiredOrders.length} expired orders`); } -} \ No newline at end of file +} diff --git a/src/modules/transactions/entities/transaction.entity.ts b/src/modules/transactions/entities/transaction.entity.ts index 6fdca46..187cc2c 100644 --- a/src/modules/transactions/entities/transaction.entity.ts +++ b/src/modules/transactions/entities/transaction.entity.ts @@ -29,7 +29,11 @@ export class Transaction extends BaseEntity { @Column({ type: 'uuid', nullable: true }) userId?: string | null; - @Column({ type: 'enum', enum: TransactionType, default: TransactionType.OTHER }) + @Column({ + type: 'enum', + enum: TransactionType, + default: TransactionType.OTHER, + }) type: TransactionType; @Index() diff --git a/src/modules/transactions/transactions.service.ts b/src/modules/transactions/transactions.service.ts index 8e6bffb..abce8f4 100644 --- a/src/modules/transactions/transactions.service.ts +++ b/src/modules/transactions/transactions.service.ts @@ -2,10 +2,7 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { PaginatedResultDto } from '@app/common'; -import { - Transaction, - TransactionStatus, -} from './entities/transaction.entity'; +import { Transaction, TransactionStatus } from './entities/transaction.entity'; import { RecordTransactionDto } from './dto/record-transaction.dto'; import { QueryTransactionDto } from './dto/query-transaction.dto'; diff --git a/src/modules/users/users.controller.ts b/src/modules/users/users.controller.ts index 3123b48..36e6927 100644 --- a/src/modules/users/users.controller.ts +++ b/src/modules/users/users.controller.ts @@ -42,10 +42,7 @@ export class UsersController { @Patch(':id') @ApiOperation({ summary: 'Update a user' }) - update( - @Param('id', ParseUUIDPipe) id: string, - @Body() dto: UpdateUserDto, - ) { + update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateUserDto) { return this.usersService.update(id, dto); } diff --git a/src/modules/wallet/dto/create-wallet.dto.ts b/src/modules/wallet/dto/create-wallet.dto.ts index 642ffc8..79215d1 100644 --- a/src/modules/wallet/dto/create-wallet.dto.ts +++ b/src/modules/wallet/dto/create-wallet.dto.ts @@ -1,20 +1,21 @@ -import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { - IsBoolean, - IsOptional, - IsString, - MaxLength, -} from 'class-validator'; +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsBoolean, IsOptional, IsString, MaxLength } from 'class-validator'; export class CreateWalletDto { - @ApiPropertyOptional({ description: 'Human-readable label for the wallet', maxLength: 100 }) + @ApiPropertyOptional({ + description: 'Human-readable label for the wallet', + maxLength: 100, + }) @IsOptional() @IsString() @MaxLength(100) label?: string; - @ApiPropertyOptional({ description: 'Set as the primary wallet for this user', default: false }) + @ApiPropertyOptional({ + description: 'Set as the primary wallet for this user', + default: false, + }) @IsOptional() @IsBoolean() isPrimary?: boolean; -} \ No newline at end of file +} diff --git a/src/modules/wallet/dto/multisig-config.dto.ts b/src/modules/wallet/dto/multisig-config.dto.ts index 58f1d95..649751e 100644 --- a/src/modules/wallet/dto/multisig-config.dto.ts +++ b/src/modules/wallet/dto/multisig-config.dto.ts @@ -9,15 +9,23 @@ import { } from 'class-validator'; export class MultisigConfigDto { - @ApiProperty({ description: 'Minimum number of signers required to authorise a transaction', minimum: 1, maximum: 10 }) + @ApiProperty({ + description: + 'Minimum number of signers required to authorise a transaction', + minimum: 1, + maximum: 10, + }) @IsInt() @Min(1) @Max(10) threshold: number; - @ApiProperty({ type: [String], description: 'Stellar public keys of the co-signers' }) + @ApiProperty({ + type: [String], + description: 'Stellar public keys of the co-signers', + }) @IsArray() @ArrayMinSize(1) @IsString({ each: true }) cosigners: string[]; -} \ No newline at end of file +} diff --git a/src/modules/wallet/dto/sign-transaction.dto.ts b/src/modules/wallet/dto/sign-transaction.dto.ts index 4f5e1d1..3e676a2 100644 --- a/src/modules/wallet/dto/sign-transaction.dto.ts +++ b/src/modules/wallet/dto/sign-transaction.dto.ts @@ -2,8 +2,10 @@ import { ApiProperty } from '@nestjs/swagger'; import { IsString, IsNotEmpty } from 'class-validator'; export class SignTransactionDto { - @ApiProperty({ description: 'Base64-encoded unsigned XDR transaction envelope' }) + @ApiProperty({ + description: 'Base64-encoded unsigned XDR transaction envelope', + }) @IsString() @IsNotEmpty() unsignedXdr: string; -} \ No newline at end of file +} diff --git a/src/modules/wallet/dto/update-wallet.dto.ts b/src/modules/wallet/dto/update-wallet.dto.ts index d63485f..c97f25d 100644 --- a/src/modules/wallet/dto/update-wallet.dto.ts +++ b/src/modules/wallet/dto/update-wallet.dto.ts @@ -9,7 +9,10 @@ import { import { WalletStatus } from '../entities/wallet.entity'; export class UpdateWalletDto { - @ApiPropertyOptional({ description: 'Human-readable label for the wallet', maxLength: 100 }) + @ApiPropertyOptional({ + description: 'Human-readable label for the wallet', + maxLength: 100, + }) @IsOptional() @IsString() @MaxLength(100) @@ -20,8 +23,10 @@ export class UpdateWalletDto { @IsEnum(WalletStatus) status?: WalletStatus; - @ApiPropertyOptional({ description: 'Set as the primary wallet for this user' }) + @ApiPropertyOptional({ + description: 'Set as the primary wallet for this user', + }) @IsOptional() @IsBoolean() isPrimary?: boolean; -} \ No newline at end of file +} diff --git a/src/modules/wallet/entities/wallet.entity.ts b/src/modules/wallet/entities/wallet.entity.ts index 9b0cbc5..b5012fd 100644 --- a/src/modules/wallet/entities/wallet.entity.ts +++ b/src/modules/wallet/entities/wallet.entity.ts @@ -56,4 +56,4 @@ export class Wallet extends BaseEntity { /** For multi-sig: JSON array of co-signer public keys. */ @Column({ type: 'jsonb', nullable: true }) cosigners?: string[]; -} \ No newline at end of file +} diff --git a/src/modules/wallet/wallet.controller.ts b/src/modules/wallet/wallet.controller.ts index 6066c09..4f94b75 100644 --- a/src/modules/wallet/wallet.controller.ts +++ b/src/modules/wallet/wallet.controller.ts @@ -30,13 +30,17 @@ export class WalletController { constructor(private readonly walletService: WalletService) {} @Post() - @ApiOperation({ summary: 'Create a new Stellar wallet for the authenticated user' }) + @ApiOperation({ + summary: 'Create a new Stellar wallet for the authenticated user', + }) create(@CurrentUser('id') userId: string, @Body() dto: CreateWalletDto) { return this.walletService.create(userId, dto); } @Get() - @ApiOperation({ summary: 'List all wallets for the authenticated user (paginated)' }) + @ApiOperation({ + summary: 'List all wallets for the authenticated user (paginated)', + }) findAll( @CurrentUser('id') userId: string, @Query() query: PaginationQueryDto, @@ -74,7 +78,9 @@ export class WalletController { } @Post(':id/sync-balance') - @ApiOperation({ summary: 'Synchronise wallet balance from the Stellar network' }) + @ApiOperation({ + summary: 'Synchronise wallet balance from the Stellar network', + }) syncBalance( @Param('id', ParseUUIDPipe) id: string, @CurrentUser('id') userId: string, @@ -83,7 +89,9 @@ export class WalletController { } @Post(':id/sign-transaction') - @ApiOperation({ summary: 'Sign an XDR transaction envelope with this wallet' }) + @ApiOperation({ + summary: 'Sign an XDR transaction envelope with this wallet', + }) signTransaction( @Param('id', ParseUUIDPipe) id: string, @CurrentUser('id') userId: string, @@ -110,4 +118,4 @@ export class WalletController { ) { return this.walletService.configureMultisig(id, userId, dto); } -} \ No newline at end of file +} diff --git a/src/modules/wallet/wallet.module.ts b/src/modules/wallet/wallet.module.ts index 8461aef..b929fe0 100644 --- a/src/modules/wallet/wallet.module.ts +++ b/src/modules/wallet/wallet.module.ts @@ -6,12 +6,9 @@ import { WalletController } from './wallet.controller'; import { StellarModule } from '../stellar/stellar.module'; @Module({ - imports: [ - TypeOrmModule.forFeature([Wallet]), - StellarModule, - ], + imports: [TypeOrmModule.forFeature([Wallet]), StellarModule], controllers: [WalletController], providers: [WalletService], exports: [WalletService], }) -export class WalletModule {} \ No newline at end of file +export class WalletModule {} diff --git a/src/modules/wallet/wallet.service.spec.ts b/src/modules/wallet/wallet.service.spec.ts index ca30519..c748280 100644 --- a/src/modules/wallet/wallet.service.spec.ts +++ b/src/modules/wallet/wallet.service.spec.ts @@ -2,7 +2,11 @@ import { Test, TestingModule } from '@nestjs/testing'; import { getRepositoryToken } from '@nestjs/typeorm'; import { ConfigService } from '@nestjs/config'; import { DataSource } from 'typeorm'; -import { NotFoundException, ForbiddenException, BadRequestException } from '@nestjs/common'; +import { + NotFoundException, + ForbiddenException, + BadRequestException, +} from '@nestjs/common'; import { WalletService } from './wallet.service'; import { Wallet, WalletStatus } from './entities/wallet.entity'; import { StellarService } from '../stellar/stellar.service'; @@ -84,7 +88,9 @@ describe('WalletService', () => { describe('create', () => { it('should create a wallet and return it', async () => { - const result = await service.create(MOCK_USER_ID, { label: 'Test Wallet' }); + const result = await service.create(MOCK_USER_ID, { + label: 'Test Wallet', + }); expect(result).toBeDefined(); expect(mockQueryRunner.commitTransaction).toHaveBeenCalled(); }); @@ -110,31 +116,33 @@ describe('WalletService', () => { it('should throw NotFoundException if wallet does not exist', async () => { mockWalletRepository.findOne.mockResolvedValueOnce(null); - await expect(service.findOne('non-existent', MOCK_USER_ID)).rejects.toThrow( - NotFoundException, - ); + await expect( + service.findOne('non-existent', MOCK_USER_ID), + ).rejects.toThrow(NotFoundException); }); it('should throw ForbiddenException if user does not own the wallet', async () => { - await expect(service.findOne(MOCK_WALLET_ID, 'other-user')).rejects.toThrow( - ForbiddenException, - ); + await expect( + service.findOne(MOCK_WALLET_ID, 'other-user'), + ).rejects.toThrow(ForbiddenException); }); }); describe('syncBalance', () => { it('should update cachedBalance from Stellar network', async () => { - const wallet = await service.syncBalance(MOCK_WALLET_ID, MOCK_USER_ID); - expect(mockStellarService.getAccount).toHaveBeenCalledWith(mockWallet.publicKey); + await service.syncBalance(MOCK_WALLET_ID, MOCK_USER_ID); + expect(mockStellarService.getAccount).toHaveBeenCalledWith( + mockWallet.publicKey, + ); expect(mockWalletRepository.save).toHaveBeenCalled(); }); }); describe('remove', () => { it('should throw BadRequestException when removing primary wallet', async () => { - await expect(service.remove(MOCK_WALLET_ID, MOCK_USER_ID)).rejects.toThrow( - BadRequestException, - ); + await expect( + service.remove(MOCK_WALLET_ID, MOCK_USER_ID), + ).rejects.toThrow(BadRequestException); }); it('should deactivate a non-primary wallet', async () => { @@ -164,4 +172,4 @@ describe('WalletService', () => { ).rejects.toThrow(); }); }); -}); \ No newline at end of file +}); diff --git a/src/modules/wallet/wallet.service.ts b/src/modules/wallet/wallet.service.ts index f7c9ba8..9454600 100644 --- a/src/modules/wallet/wallet.service.ts +++ b/src/modules/wallet/wallet.service.ts @@ -21,7 +21,6 @@ import { MultisigConfigDto } from './dto/multisig-config.dto'; const ALGORITHM = 'aes-256-gcm'; const IV_LENGTH = 16; -const AUTH_TAG_LENGTH = 16; /** * Wallet Management Service @@ -89,7 +88,9 @@ export class WalletService { const encryptedSecretKey = this.encrypt(keypair.secret()); // If this is the first wallet for the user, auto-set it as primary - const existingCount = await this.walletRepository.count({ where: { userId } }); + const existingCount = await this.walletRepository.count({ + where: { userId }, + }); const isPrimary = dto.isPrimary ?? existingCount === 0; const queryRunner = this.dataSource.createQueryRunner(); @@ -155,7 +156,11 @@ export class WalletService { } /** Updates mutable wallet fields (label, status, isPrimary). */ - async update(id: string, userId: string, dto: UpdateWalletDto): Promise { + async update( + id: string, + userId: string, + dto: UpdateWalletDto, + ): Promise { const wallet = await this.findOne(id, userId); const queryRunner = this.dataSource.createQueryRunner(); @@ -187,7 +192,9 @@ export class WalletService { async remove(id: string, userId: string): Promise { const wallet = await this.findOne(id, userId); if (wallet.isPrimary) { - throw new BadRequestException('Cannot remove the primary wallet. Assign another wallet as primary first.'); + throw new BadRequestException( + 'Cannot remove the primary wallet. Assign another wallet as primary first.', + ); } wallet.status = WalletStatus.INACTIVE; await this.walletRepository.save(wallet); @@ -204,14 +211,18 @@ export class WalletService { const wallet = await this.findOne(id, userId); try { - const accountSummary = await this.stellarService.getAccount(wallet.publicKey); + const accountSummary = await this.stellarService.getAccount( + wallet.publicKey, + ); const xlmBalance = accountSummary.balances.find( (b) => b.assetType === 'native', ); wallet.cachedBalance = xlmBalance?.balance ?? '0'; wallet.balanceSyncedAt = new Date(); await this.walletRepository.save(wallet); - this.logger.log(`Balance synced for wallet ${id}: ${wallet.cachedBalance} XLM`); + this.logger.log( + `Balance synced for wallet ${id}: ${wallet.cachedBalance} XLM`, + ); } catch (error) { this.logger.warn( `Balance sync failed for wallet ${id}: ${(error as Error).message}`, @@ -246,7 +257,9 @@ export class WalletService { throw new ForbiddenException('You do not own this wallet'); } if (wallet.status !== WalletStatus.ACTIVE) { - throw new BadRequestException(`Wallet is ${wallet.status} and cannot sign transactions`); + throw new BadRequestException( + `Wallet is ${wallet.status} and cannot sign transactions`, + ); } const secretKey = this.decrypt(wallet.encryptedSecretKey); @@ -254,7 +267,8 @@ export class WalletService { const transaction = TransactionBuilder.fromXDR( dto.unsignedXdr, - this.configService.get('stellar.networkPassphrase') ?? 'Test SDF Network ; September 2015', + this.configService.get('stellar.networkPassphrase') ?? + 'Test SDF Network ; September 2015', ); transaction.sign(keypair); @@ -270,7 +284,10 @@ export class WalletService { * Returns the public key for a wallet — safe to expose for recovery flows * (e.g. re-deriving trust lines or verifying ownership). */ - async getPublicKey(id: string, userId: string): Promise<{ publicKey: string }> { + async getPublicKey( + id: string, + userId: string, + ): Promise<{ publicKey: string }> { const wallet = await this.findOne(id, userId); return { publicKey: wallet.publicKey }; } @@ -286,11 +303,13 @@ export class WalletService { const wallet = await this.findOne(id, userId); if (dto.cosigners.includes(wallet.publicKey)) { - throw new ConflictException('Wallet public key cannot be listed as a co-signer of itself'); + throw new ConflictException( + 'Wallet public key cannot be listed as a co-signer of itself', + ); } wallet.multisigThreshold = dto.threshold; wallet.cosigners = dto.cosigners; return this.walletRepository.save(wallet); } -} \ No newline at end of file +}