Skip to content
Merged
4 changes: 2 additions & 2 deletions apps/backend/src/stellar/stellar.module.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -15,7 +15,7 @@ import { AppCacheModule } from '../cache/cache.module';
@Module({
imports: [
ConfigModule.forFeature(stellarConfig),
TransactionModule,
forwardRef(() => TransactionModule),
AuditModule,
AppConfigModule,
AppCacheModule,
Expand Down
18 changes: 18 additions & 0 deletions apps/backend/src/transaction/dto/transaction-callback.dto.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -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;
}
21 changes: 21 additions & 0 deletions apps/backend/src/transaction/transaction-status.controller.ts
Original file line number Diff line number Diff line change
@@ -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' };
}
}
166 changes: 166 additions & 0 deletions apps/backend/src/transaction/transaction-status.service.ts
Original file line number Diff line number Diff line change
@@ -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<TransactionCallback>,
private readonly sorobanRpcService: SorobanRpcClientService,
private readonly httpService: HttpService,
private readonly webhookService: WebhookService,
) {}

async registerCallback(
dto: RegisterTransactionCallbackDto,
): Promise<TransactionCallback> {
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);
}
}
}
24 changes: 19 additions & 5 deletions apps/backend/src/transaction/transaction.module.ts
Original file line number Diff line number Diff line change
@@ -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 {}
20 changes: 20 additions & 0 deletions apps/backend/src/webhook/webhook.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,26 @@ export class WebhookService {
this.webhookSecret = this.configService.get<string>('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
Expand Down
Loading