diff --git a/apps/backend/src/stellar/stellar.module.ts b/apps/backend/src/stellar/stellar.module.ts index 687b90af..f0ddaa58 100644 --- a/apps/backend/src/stellar/stellar.module.ts +++ b/apps/backend/src/stellar/stellar.module.ts @@ -1,4 +1,4 @@ -import { Module } from '@nestjs/common'; +import { Module, forwardRef } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import stellarConfig from './config/stellar.config'; import { StellarController } from './stellar.controller'; @@ -15,7 +15,7 @@ import { AppCacheModule } from '../cache/cache.module'; @Module({ imports: [ ConfigModule.forFeature(stellarConfig), - TransactionModule, + forwardRef(() => TransactionModule), AuditModule, AppConfigModule, AppCacheModule, diff --git a/apps/backend/src/transaction/dto/transaction-callback.dto.ts b/apps/backend/src/transaction/dto/transaction-callback.dto.ts new file mode 100644 index 00000000..c12e4429 --- /dev/null +++ b/apps/backend/src/transaction/dto/transaction-callback.dto.ts @@ -0,0 +1,18 @@ +import { IsString, IsUrl, IsNotEmpty } from 'class-validator'; + +export class RegisterTransactionCallbackDto { + @IsString() + @IsNotEmpty() + transactionHash: string; + + @IsUrl() + @IsNotEmpty() + callbackUrl: string; +} + +export class TransactionStatusUpdateDto { + transactionHash: string; + status: 'PENDING' | 'SUCCESS' | 'FAILED'; + timestamp: string; + error?: string; +} diff --git a/apps/backend/src/transaction/entities/transaction-callback.entity.ts b/apps/backend/src/transaction/entities/transaction-callback.entity.ts new file mode 100644 index 00000000..8a9331ac --- /dev/null +++ b/apps/backend/src/transaction/entities/transaction-callback.entity.ts @@ -0,0 +1,47 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, +} from 'typeorm'; + +export enum TransactionCallbackStatus { + PENDING = 'PENDING', + FINALIZED = 'FINALIZED', + NOTIFIED = 'NOTIFIED', + FAILED_TO_NOTIFY = 'FAILED_TO_NOTIFY', +} + +@Entity('transaction_callbacks') +export class TransactionCallback { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index({ unique: true }) + @Column() + transactionHash: string; + + @Column() + callbackUrl: string; + + @Column({ + type: 'enum', + enum: TransactionCallbackStatus, + default: TransactionCallbackStatus.PENDING, + }) + status: TransactionCallbackStatus; + + @Column({ nullable: true }) + lastError: string; + + @Column({ default: 0 }) + retryCount: number; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/apps/backend/src/transaction/transaction-status.controller.ts b/apps/backend/src/transaction/transaction-status.controller.ts new file mode 100644 index 00000000..3c02a633 --- /dev/null +++ b/apps/backend/src/transaction/transaction-status.controller.ts @@ -0,0 +1,21 @@ +import { Controller, Post, Body, HttpCode, HttpStatus } from '@nestjs/common'; +import { TransactionStatusService } from './transaction-status.service'; +import { RegisterTransactionCallbackDto } from './dto/transaction-callback.dto'; +import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger'; + +@ApiTags('Transaction Status') +@Controller('transactions/status') +export class TransactionStatusController { + constructor(private readonly statusService: TransactionStatusService) {} + + @Post('callback') + @HttpCode(HttpStatus.ACCEPTED) + @ApiOperation({ + summary: 'Register a callback URL for transaction status updates', + }) + @ApiResponse({ status: 202, description: 'Callback registered successfully' }) + async registerCallback(@Body() dto: RegisterTransactionCallbackDto) { + await this.statusService.registerCallback(dto); + return { message: 'Callback registered successfully' }; + } +} diff --git a/apps/backend/src/transaction/transaction-status.service.ts b/apps/backend/src/transaction/transaction-status.service.ts new file mode 100644 index 00000000..a82c1d4f --- /dev/null +++ b/apps/backend/src/transaction/transaction-status.service.ts @@ -0,0 +1,166 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, In } from 'typeorm'; +import { Cron, CronExpression } from '@nestjs/schedule'; +import { HttpService } from '@nestjs/axios'; +import { firstValueFrom } from 'rxjs'; +import { + TransactionCallback, + TransactionCallbackStatus, +} from './entities/transaction-callback.entity'; +import { + RegisterTransactionCallbackDto, + TransactionStatusUpdateDto, +} from './dto/transaction-callback.dto'; +import { SorobanRpcClientService } from '../stellar/services/soroban-rpc-client.service'; +import { WebhookService } from '../webhook/webhook.service'; + +@Injectable() +export class TransactionStatusService { + private readonly logger = new Logger(TransactionStatusService.name); + + constructor( + @InjectRepository(TransactionCallback) + private readonly callbackRepository: Repository, + private readonly sorobanRpcService: SorobanRpcClientService, + private readonly httpService: HttpService, + private readonly webhookService: WebhookService, + ) {} + + async registerCallback( + dto: RegisterTransactionCallbackDto, + ): Promise { + const existing = await this.callbackRepository.findOne({ + where: { transactionHash: dto.transactionHash }, + }); + + if (existing) { + return existing; + } + + const callback = this.callbackRepository.create({ + transactionHash: dto.transactionHash, + callbackUrl: dto.callbackUrl, + status: TransactionCallbackStatus.PENDING, + }); + + return this.callbackRepository.save(callback); + } + + @Cron(CronExpression.EVERY_30_SECONDS) + async processCallbacks() { + this.logger.log('Processing transaction callbacks...'); + + // 1. Poll PENDING transactions to see if they are finalized + await this.pollPendingTransactions(); + + // 2. Retry notifications for FINALIZED or FAILED_TO_NOTIFY + await this.retryNotifications(); + } + + private async pollPendingTransactions() { + const pendingCallbacks = await this.callbackRepository.find({ + where: { status: TransactionCallbackStatus.PENDING }, + }); + + if (pendingCallbacks.length === 0) return; + + for (const callback of pendingCallbacks) { + try { + const txResponse = await this.sorobanRpcService.getTransaction( + callback.transactionHash, + ); + const currentStatus = String(txResponse.status); + + if (currentStatus === 'SUCCESS' || currentStatus === 'FAILED') { + this.logger.log( + `Transaction ${callback.transactionHash} finalized with status ${txResponse.status}`, + ); + + callback.status = TransactionCallbackStatus.FINALIZED; + if (currentStatus === 'FAILED') { + const errorResult = (txResponse as { errorResult?: unknown }) + .errorResult; + callback.lastError = JSON.stringify(errorResult ?? 'Unknown error'); + } + + await this.callbackRepository.save(callback); + await this.notifyCallback(callback, currentStatus); + } + } catch (error) { + const errorMsg = + error instanceof Error ? error.message : 'Unknown error'; + this.logger.error( + `Failed to check status for transaction ${callback.transactionHash}: ${errorMsg}`, + ); + } + } + } + + private async retryNotifications() { + const needingNotification = await this.callbackRepository.find({ + where: { + status: In([ + TransactionCallbackStatus.FINALIZED, + TransactionCallbackStatus.FAILED_TO_NOTIFY, + ]), + }, + }); + + for (const callback of needingNotification) { + try { + const txResponse = await this.sorobanRpcService.getTransaction( + callback.transactionHash, + ); + await this.notifyCallback(callback, String(txResponse.status)); + } catch (error) { + const errorMsg = + error instanceof Error ? error.message : 'Unknown error'; + this.logger.error( + `Failed to retry notification for ${callback.transactionHash}: ${errorMsg}`, + ); + } + } + } + + private async notifyCallback( + callback: TransactionCallback, + txStatus: string, + ) { + const payload: TransactionStatusUpdateDto = { + transactionHash: callback.transactionHash, + status: txStatus === 'SUCCESS' ? 'SUCCESS' : 'FAILED', + timestamp: new Date().toISOString(), + error: callback.lastError, + }; + + const signature = this.webhookService.signPayload(payload); + + try { + await firstValueFrom( + this.httpService.post(callback.callbackUrl, payload, { + timeout: 5000, + headers: { + 'X-Webhook-Signature': signature, + }, + }), + ); + + callback.status = TransactionCallbackStatus.NOTIFIED; + callback.retryCount = 0; + await this.callbackRepository.save(callback); + this.logger.log( + `Successfully notified callback for ${callback.transactionHash}`, + ); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : 'Unknown error'; + this.logger.error( + `Failed to notify callback for ${callback.transactionHash}: ${errorMsg}`, + ); + callback.retryCount += 1; + callback.status = TransactionCallbackStatus.FAILED_TO_NOTIFY; + callback.lastError = `Notification failed: ${errorMsg}`; + await this.callbackRepository.save(callback); + } + } +} diff --git a/apps/backend/src/transaction/transaction.module.ts b/apps/backend/src/transaction/transaction.module.ts index 30183c85..72a7adb9 100644 --- a/apps/backend/src/transaction/transaction.module.ts +++ b/apps/backend/src/transaction/transaction.module.ts @@ -1,13 +1,27 @@ -import { Module } from '@nestjs/common'; +import { Module, forwardRef } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { HttpModule } from '@nestjs/axios'; import { TransactionController } from './transaction.controller'; +import { TransactionStatusController } from './transaction-status.controller'; import { TransactionService } from './transaction.service'; +import { TransactionStatusService } from './transaction-status.service'; +import { TransactionCallback } from './entities/transaction-callback.entity'; import { UsersModule } from '../users/users.module'; import { AppCacheModule } from '../cache/cache.module'; +import { StellarModule } from '../stellar/stellar.module'; +import { WebhookModule } from '../webhook/webhook.module'; @Module({ - imports: [UsersModule, AppCacheModule], - controllers: [TransactionController], - providers: [TransactionService], - exports: [TransactionService], + imports: [ + TypeOrmModule.forFeature([TransactionCallback]), + HttpModule, + UsersModule, + AppCacheModule, + forwardRef(() => StellarModule), + WebhookModule, + ], + controllers: [TransactionController, TransactionStatusController], + providers: [TransactionService, TransactionStatusService], + exports: [TransactionService, TransactionStatusService], }) export class TransactionModule {} diff --git a/apps/backend/src/webhook/webhook.service.ts b/apps/backend/src/webhook/webhook.service.ts index f8e4a66b..88d57ae0 100644 --- a/apps/backend/src/webhook/webhook.service.ts +++ b/apps/backend/src/webhook/webhook.service.ts @@ -28,6 +28,26 @@ export class WebhookService { this.webhookSecret = this.configService.get('WEBHOOK_SECRET', ''); } + /** + * Sign a payload using the webhook secret for outgoing notifications + */ + signPayload(payload: any): string { + if (!this.webhookSecret) { + this.logger.warn( + 'WEBHOOK_SECRET is not set — cannot sign outgoing webhook', + ); + return ''; + } + + const body = JSON.stringify(payload); + const hash = crypto + .createHmac('sha256', this.webhookSecret) + .update(body) + .digest('hex'); + + return `sha256=${hash}`; + } + /** * Legacy signature verification (kept for backward compatibility) * New implementations should use WebhookVerificationGuard