From 0b2af3abcd869e830b18c383ddb313d62c386030 Mon Sep 17 00:00:00 2001 From: grantfox-issue-solver Date: Sat, 22 Aug 2026 18:08:38 +0000 Subject: [PATCH] fix: resolve issue #26 --- .../blockchain/blockchain.controller.ts | 107 ++++++++++++ src/modules/blockchain/blockchain.module.ts | 5 +- src/modules/blockchain/blockchain.service.ts | 153 +++++++++++++++++- src/modules/loans/loans.controller.ts | 3 +- ...00000_create_idempotency_records_table.sql | 16 ++ 5 files changed, 279 insertions(+), 5 deletions(-) create mode 100644 src/modules/blockchain/blockchain.controller.ts create mode 100644 supabase/migrations/20260901000000_create_idempotency_records_table.sql diff --git a/src/modules/blockchain/blockchain.controller.ts b/src/modules/blockchain/blockchain.controller.ts new file mode 100644 index 0000000..899d76b --- /dev/null +++ b/src/modules/blockchain/blockchain.controller.ts @@ -0,0 +1,107 @@ +import { + Controller, + Post, + Body, + HttpCode, + HttpStatus, + UseGuards, + Headers, + BadRequestException, +} from '@nestjs/common'; +import { + ApiTags, + ApiOperation, + ApiResponse, + ApiBearerAuth, + ApiHeader, +} from '@nestjs/swagger'; +import { IsString, IsNotEmpty, IsOptional } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; +import { BlockchainService } from './blockchain.service'; +import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; + +class SubmitTransactionRequestDto { + @ApiProperty({ + description: 'Signed XDR transaction string to submit to the Stellar network', + example: 'AAAAAgAAAAA...', + }) + @IsString({ message: 'xdr must be a string' }) + @IsNotEmpty({ message: 'xdr must not be empty' }) + xdr: string; + + @ApiProperty({ + description: 'Idempotency key to deduplicate repeated submissions', + example: 'repay_loan_12345', + required: false, + }) + @IsOptional() + @IsString({ message: 'idempotencyKey must be a string' }) + idempotencyKey?: string; +} + +@ApiTags('blockchain') +@Controller('blockchain') +export class BlockchainController { + constructor(private readonly blockchainService: BlockchainService) {} + + @Post('submit') + @HttpCode(HttpStatus.OK) + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @ApiHeader({ + name: 'Idempotency-Key', + description: 'Optional idempotency key to deduplicate repeated submissions', + required: false, + }) + @ApiOperation({ + summary: 'Submit a signed XDR transaction idempotently', + description: + 'Validates the XDR, deduplicates by transaction hash, and returns the same response for repeated submissions with the same idempotency key.', + }) + @ApiResponse({ + status: 200, + description: 'Transaction submitted successfully', + schema: { + properties: { + success: { type: 'boolean', example: true }, + data: { + properties: { + transactionHash: { + type: 'string', + example: 'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2', + }, + }, + }, + message: { type: 'string', example: 'Transaction submitted successfully' }, + }, + }, + }) + @ApiResponse({ status: 400, description: 'Invalid XDR' }) + @ApiResponse({ status: 401, description: 'Unauthorized - missing or invalid JWT' }) + @ApiResponse({ status: 409, description: 'Duplicate transaction or idempotency key in use' }) + @ApiResponse({ status: 503, description: 'Stellar network unavailable or confirmation timeout' }) + async submitTransaction( + @Body() dto: SubmitTransactionRequestDto, + @Headers('idempotency-key') idempotencyKeyHeader?: string, + ): Promise<{ success: boolean; data: { transactionHash: string }; message: string }> { + const idempotencyKey = dto.idempotencyKey ?? idempotencyKeyHeader; + + if (idempotencyKey !== undefined && idempotencyKey.trim() === '') { + throw new BadRequestException({ + code: 'IDEMPOTENCY_KEY_INVALID', + message: 'Idempotency key must not be empty.', + }); + } + + const data = await this.blockchainService.submitRepayment( + dto.xdr, + idempotencyKey, + ); + + return { + success: true, + data, + message: 'Transaction submitted successfully', + }; + } +} diff --git a/src/modules/blockchain/blockchain.module.ts b/src/modules/blockchain/blockchain.module.ts index 3b20dbf..1a7c307 100644 --- a/src/modules/blockchain/blockchain.module.ts +++ b/src/modules/blockchain/blockchain.module.ts @@ -1,10 +1,13 @@ import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import { BlockchainService } from './blockchain.service'; +import { BlockchainController } from './blockchain.controller'; +import { SupabaseService } from '../../database/supabase.client'; @Module({ imports: [ConfigModule], - providers: [BlockchainService], + controllers: [BlockchainController], + providers: [BlockchainService, SupabaseService], exports: [BlockchainService], }) export class BlockchainModule {} diff --git a/src/modules/blockchain/blockchain.service.ts b/src/modules/blockchain/blockchain.service.ts index 1f94532..8d9b1dc 100644 --- a/src/modules/blockchain/blockchain.service.ts +++ b/src/modules/blockchain/blockchain.service.ts @@ -2,11 +2,24 @@ import { Injectable, Logger, BadRequestException, + ConflictException, InternalServerErrorException, ServiceUnavailableException, } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import * as StellarSdk from 'stellar-sdk'; +import { SupabaseService } from '../../database/supabase.client'; + +const IDEMPOTENCY_RECORD_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours + +type IdempotencyRecord = { + id: string; + idempotency_key: string; + transaction_hash: string; + response_body: Record | null; + status: string; + expires_at: string; +}; @Injectable() export class BlockchainService { @@ -14,7 +27,10 @@ export class BlockchainService { private readonly horizonServer: StellarSdk.Horizon.Server; private readonly networkPassphrase: string; - constructor(private readonly configService: ConfigService) { + constructor( + private readonly configService: ConfigService, + private readonly supabaseService: SupabaseService, + ) { const horizonUrl = this.configService.get('STELLAR_HORIZON_URL') || 'https://horizon-testnet.stellar.org'; @@ -27,14 +43,52 @@ export class BlockchainService { this.logger.log(`BlockchainService Horizon client initialized: ${horizonUrl}`); } - async submitRepayment(signedXdr: string): Promise<{ transactionHash: string }> { + async submitRepayment( + signedXdr: string, + idempotencyKey?: string, + ): Promise<{ transactionHash: string }> { const transaction = this.parseTransaction(signedXdr); + const transactionHash = transaction.hash().toString('hex'); + + if (idempotencyKey) { + const existing = await this.findIdempotencyRecord(idempotencyKey); + if (existing) { + if (existing.status === 'completed' && existing.response_body) { + return existing.response_body as { transactionHash: string }; + } + throw new ConflictException({ + code: 'IDEMPOTENCY_KEY_IN_USE', + message: 'This idempotency key is already associated with an in-flight transaction.', + }); + } + } + + const duplicate = await this.findRecordByHash(transactionHash); + if (duplicate) { + if (duplicate.status === 'completed' && duplicate.response_body) { + return duplicate.response_body as { transactionHash: string }; + } + throw new ConflictException({ + code: 'TRANSACTION_ALREADY_SUBMITTED', + message: 'This transaction has already been submitted.', + }); + } + + if (idempotencyKey) { + await this.createIdempotencyRecord(idempotencyKey, transactionHash, signedXdr); + } const hash = await this.submitToHorizon(transaction); await this.waitForLedgerConfirmation(hash); - return { transactionHash: hash }; + const response = { transactionHash: hash }; + + if (idempotencyKey) { + await this.completeIdempotencyRecord(idempotencyKey, response); + } + + return response; } private parseTransaction(signedXdr: string): StellarSdk.Transaction { @@ -107,6 +161,99 @@ export class BlockchainService { }); } + private async findIdempotencyRecord( + idempotencyKey: string, + ): Promise { + const client = this.supabaseService.getServiceRoleClient(); + const { data, error } = await client + .from('idempotency_records') + .select('*') + .eq('idempotency_key', idempotencyKey) + .gt('expires_at', new Date().toISOString()) + .maybeSingle(); + + if (error) { + this.logger.error( + `Failed to query idempotency record for key ${idempotencyKey}: ${error.message}`, + ); + throw new InternalServerErrorException({ + code: 'IDEMPOTENCY_LOOKUP_FAILED', + message: 'Failed to check idempotency record.', + }); + } + + return data as IdempotencyRecord | null; + } + + private async findRecordByHash( + transactionHash: string, + ): Promise { + const client = this.supabaseService.getServiceRoleClient(); + const { data, error } = await client + .from('idempotency_records') + .select('*') + .eq('transaction_hash', transactionHash) + .gt('expires_at', new Date().toISOString()) + .maybeSingle(); + + if (error) { + this.logger.error( + `Failed to query idempotency record for hash ${transactionHash}: ${error.message}`, + ); + throw new InternalServerErrorException({ + code: 'IDEMPOTENCY_LOOKUP_FAILED', + message: 'Failed to check transaction hash deduplication.', + }); + } + + return data as IdempotencyRecord | null; + } + + private async createIdempotencyRecord( + idempotencyKey: string, + transactionHash: string, + signedXdr: string, + ): Promise { + const client = this.supabaseService.getServiceRoleClient(); + const { error } = await client.from('idempotency_records').insert({ + idempotency_key: idempotencyKey, + transaction_hash: transactionHash, + request_body: { xdr: signedXdr }, + status: 'pending', + expires_at: new Date(Date.now() + IDEMPOTENCY_RECORD_TTL_MS).toISOString(), + }); + + if (error) { + this.logger.error( + `Failed to create idempotency record for key ${idempotencyKey}: ${error.message}`, + ); + throw new InternalServerErrorException({ + code: 'IDEMPOTENCY_RECORD_CREATE_FAILED', + message: 'Failed to create idempotency record.', + }); + } + } + + private async completeIdempotencyRecord( + idempotencyKey: string, + response: { transactionHash: string }, + ): Promise { + const client = this.supabaseService.getServiceRoleClient(); + const { error } = await client + .from('idempotency_records') + .update({ + status: 'completed', + response_body: response, + }) + .eq('idempotency_key', idempotencyKey); + + if (error) { + this.logger.error( + `Failed to complete idempotency record for key ${idempotencyKey}: ${error.message}`, + ); + } + } + private handleHorizonError(error: unknown): never { const err = error as { response?: { diff --git a/src/modules/loans/loans.controller.ts b/src/modules/loans/loans.controller.ts index 7b9d253..69fba18 100644 --- a/src/modules/loans/loans.controller.ts +++ b/src/modules/loans/loans.controller.ts @@ -245,8 +245,9 @@ export class LoansController { async submitRepayment( @Param('loanId', ParseUUIDPipe) loanId: string, @Body('xdr') signedXdr: string, + @Body('idempotencyKey') idempotencyKey?: string, ) { - const data = await this.blockchainService.submitRepayment(signedXdr); + const data = await this.blockchainService.submitRepayment(signedXdr, idempotencyKey); return { success: true, data, message: 'Repayment submitted and confirmed successfully' }; } diff --git a/supabase/migrations/20260901000000_create_idempotency_records_table.sql b/supabase/migrations/20260901000000_create_idempotency_records_table.sql new file mode 100644 index 0000000..32af534 --- /dev/null +++ b/supabase/migrations/20260901000000_create_idempotency_records_table.sql @@ -0,0 +1,16 @@ +CREATE TABLE public.idempotency_records ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + idempotency_key TEXT NOT NULL, + transaction_hash TEXT NOT NULL, + request_body JSONB NOT NULL, + response_body JSONB, + status TEXT NOT NULL DEFAULT 'pending', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + expires_at TIMESTAMPTZ NOT NULL DEFAULT (now() + interval '24 hours') +); + +CREATE UNIQUE INDEX idempotency_records_key_idx ON public.idempotency_records (idempotency_key); +CREATE UNIQUE INDEX idempotency_records_hash_idx ON public.idempotency_records (transaction_hash); +CREATE INDEX idempotency_records_expires_at_idx ON public.idempotency_records (expires_at); + +ALTER TABLE public.idempotency_records ENABLE ROW LEVEL SECURITY;