From 117a5c33058085c1296edf207c2e3d2118db8ae5 Mon Sep 17 00:00:00 2001 From: Solidsole Date: Sat, 27 Jun 2026 14:56:11 +0000 Subject: [PATCH 1/3] #856 Backend: Transaction status push webhook for frontend polling offload FIXED --- apps/backend/src/stellar/stellar.module.ts | 4 +- .../dto/transaction-callback.dto.ts | 18 +++ .../entities/transaction-callback.entity.ts | 40 ++++++ .../transaction-status.controller.ts | 19 +++ .../transaction/transaction-status.service.ts | 130 ++++++++++++++++++ .../src/transaction/transaction.module.ts | 24 +++- apps/backend/src/webhook/webhook.service.ts | 20 +++ 7 files changed, 248 insertions(+), 7 deletions(-) create mode 100644 apps/backend/src/transaction/dto/transaction-callback.dto.ts create mode 100644 apps/backend/src/transaction/entities/transaction-callback.entity.ts create mode 100644 apps/backend/src/transaction/transaction-status.controller.ts create mode 100644 apps/backend/src/transaction/transaction-status.service.ts 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..f552947b --- /dev/null +++ b/apps/backend/src/transaction/entities/transaction-callback.entity.ts @@ -0,0 +1,40 @@ +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..7ff4a13e --- /dev/null +++ b/apps/backend/src/transaction/transaction-status.controller.ts @@ -0,0 +1,19 @@ +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..af69e165 --- /dev/null +++ b/apps/backend/src/transaction/transaction-status.service.ts @@ -0,0 +1,130 @@ +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); + + if (txResponse.status === 'SUCCESS' || txResponse.status === 'FAILED') { + this.logger.log(`Transaction ${callback.transactionHash} finalized with status ${txResponse.status}`); + + callback.status = TransactionCallbackStatus.FINALIZED; + if (txResponse.status === 'FAILED') { + callback.lastError = JSON.stringify((txResponse as any).errorResult ?? 'Unknown error'); + } + + await this.callbackRepository.save(callback); + await this.notifyCallback(callback, txResponse.status); + } + } catch (error) { + this.logger.error(`Failed to check status for transaction ${callback.transactionHash}: ${error.message}`); + } + } + } + + 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, txResponse.status); + } catch (error) { + this.logger.error(`Failed to retry notification for ${callback.transactionHash}: ${error.message}`); + } + } + } + + 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) { + this.logger.error(`Failed to notify callback for ${callback.transactionHash}: ${error.message}`); + callback.retryCount += 1; + callback.status = TransactionCallbackStatus.FAILED_TO_NOTIFY; + callback.lastError = `Notification failed: ${error.message}`; + 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 From 55070e8b8df046d15b6102cc1af9988a0d70e06e Mon Sep 17 00:00:00 2001 From: Solidsole Date: Sat, 27 Jun 2026 20:29:01 +0000 Subject: [PATCH 2/3] fix(backend): resolve eslint unsafe enum comparison and any member access --- .../transaction/transaction-status.service.ts | 25 +++++++++++-------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/apps/backend/src/transaction/transaction-status.service.ts b/apps/backend/src/transaction/transaction-status.service.ts index af69e165..68c38d42 100644 --- a/apps/backend/src/transaction/transaction-status.service.ts +++ b/apps/backend/src/transaction/transaction-status.service.ts @@ -60,20 +60,23 @@ export class TransactionStatusService { for (const callback of pendingCallbacks) { try { const txResponse = await this.sorobanRpcService.getTransaction(callback.transactionHash); + const currentStatus = String(txResponse.status); - if (txResponse.status === 'SUCCESS' || txResponse.status === 'FAILED') { + if (currentStatus === 'SUCCESS' || currentStatus === 'FAILED') { this.logger.log(`Transaction ${callback.transactionHash} finalized with status ${txResponse.status}`); callback.status = TransactionCallbackStatus.FINALIZED; - if (txResponse.status === 'FAILED') { - callback.lastError = JSON.stringify((txResponse as any).errorResult ?? 'Unknown error'); + if (currentStatus === 'FAILED') { + const extendedTx = txResponse as Record; + callback.lastError = JSON.stringify(extendedTx.errorResult ?? 'Unknown error'); } await this.callbackRepository.save(callback); - await this.notifyCallback(callback, txResponse.status); + await this.notifyCallback(callback, currentStatus); } } catch (error) { - this.logger.error(`Failed to check status for transaction ${callback.transactionHash}: ${error.message}`); + const errorMsg = error instanceof Error ? error.message : 'Unknown error'; + this.logger.error(`Failed to check status for transaction ${callback.transactionHash}: ${errorMsg}`); } } } @@ -88,9 +91,10 @@ export class TransactionStatusService { for (const callback of needingNotification) { try { const txResponse = await this.sorobanRpcService.getTransaction(callback.transactionHash); - await this.notifyCallback(callback, txResponse.status); + await this.notifyCallback(callback, String(txResponse.status)); } catch (error) { - this.logger.error(`Failed to retry notification for ${callback.transactionHash}: ${error.message}`); + const errorMsg = error instanceof Error ? error.message : 'Unknown error'; + this.logger.error(`Failed to retry notification for ${callback.transactionHash}: ${errorMsg}`); } } } @@ -120,11 +124,12 @@ export class TransactionStatusService { await this.callbackRepository.save(callback); this.logger.log(`Successfully notified callback for ${callback.transactionHash}`); } catch (error) { - this.logger.error(`Failed to notify callback for ${callback.transactionHash}: ${error.message}`); + 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: ${error.message}`; + callback.lastError = `Notification failed: ${errorMsg}`; await this.callbackRepository.save(callback); } } -} +} \ No newline at end of file From 94499f3d1d9d1d88ceca659ac5f597cfb2e7e095 Mon Sep 17 00:00:00 2001 From: Solidsole Date: Mon, 29 Jun 2026 05:19:54 +0000 Subject: [PATCH 3/3] fix: address transaction status callback CI issue --- .../entities/transaction-callback.entity.ts | 9 ++- .../transaction-status.controller.ts | 4 +- .../transaction/transaction-status.service.ts | 73 +++++++++++++------ 3 files changed, 63 insertions(+), 23 deletions(-) diff --git a/apps/backend/src/transaction/entities/transaction-callback.entity.ts b/apps/backend/src/transaction/entities/transaction-callback.entity.ts index f552947b..8a9331ac 100644 --- a/apps/backend/src/transaction/entities/transaction-callback.entity.ts +++ b/apps/backend/src/transaction/entities/transaction-callback.entity.ts @@ -1,4 +1,11 @@ -import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, Index } from 'typeorm'; +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, +} from 'typeorm'; export enum TransactionCallbackStatus { PENDING = 'PENDING', diff --git a/apps/backend/src/transaction/transaction-status.controller.ts b/apps/backend/src/transaction/transaction-status.controller.ts index 7ff4a13e..3c02a633 100644 --- a/apps/backend/src/transaction/transaction-status.controller.ts +++ b/apps/backend/src/transaction/transaction-status.controller.ts @@ -10,7 +10,9 @@ export class TransactionStatusController { @Post('callback') @HttpCode(HttpStatus.ACCEPTED) - @ApiOperation({ summary: 'Register a callback URL for transaction status updates' }) + @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); diff --git a/apps/backend/src/transaction/transaction-status.service.ts b/apps/backend/src/transaction/transaction-status.service.ts index 68c38d42..a82c1d4f 100644 --- a/apps/backend/src/transaction/transaction-status.service.ts +++ b/apps/backend/src/transaction/transaction-status.service.ts @@ -4,8 +4,14 @@ 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 { + 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'; @@ -21,7 +27,9 @@ export class TransactionStatusService { private readonly webhookService: WebhookService, ) {} - async registerCallback(dto: RegisterTransactionCallbackDto): Promise { + async registerCallback( + dto: RegisterTransactionCallbackDto, + ): Promise { const existing = await this.callbackRepository.findOne({ where: { transactionHash: dto.transactionHash }, }); @@ -59,47 +67,66 @@ export class TransactionStatusService { for (const callback of pendingCallbacks) { try { - const txResponse = await this.sorobanRpcService.getTransaction(callback.transactionHash); + 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}`); - + this.logger.log( + `Transaction ${callback.transactionHash} finalized with status ${txResponse.status}`, + ); + callback.status = TransactionCallbackStatus.FINALIZED; if (currentStatus === 'FAILED') { - const extendedTx = txResponse as Record; - callback.lastError = JSON.stringify(extendedTx.errorResult ?? 'Unknown error'); + 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}`); + 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]) + where: { + status: In([ + TransactionCallbackStatus.FINALIZED, + TransactionCallbackStatus.FAILED_TO_NOTIFY, + ]), }, }); for (const callback of needingNotification) { try { - const txResponse = await this.sorobanRpcService.getTransaction(callback.transactionHash); + 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}`); + 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) { + private async notifyCallback( + callback: TransactionCallback, + txStatus: string, + ) { const payload: TransactionStatusUpdateDto = { transactionHash: callback.transactionHash, status: txStatus === 'SUCCESS' ? 'SUCCESS' : 'FAILED', @@ -122,14 +149,18 @@ export class TransactionStatusService { callback.status = TransactionCallbackStatus.NOTIFIED; callback.retryCount = 0; await this.callbackRepository.save(callback); - this.logger.log(`Successfully notified callback for ${callback.transactionHash}`); + 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}`); + 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); } } -} \ No newline at end of file +}