Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 107 additions & 0 deletions src/modules/blockchain/blockchain.controller.ts
Original file line number Diff line number Diff line change
@@ -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',
};
}
}
5 changes: 4 additions & 1 deletion src/modules/blockchain/blockchain.module.ts
Original file line number Diff line number Diff line change
@@ -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 {}
153 changes: 150 additions & 3 deletions src/modules/blockchain/blockchain.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,35 @@ 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<string, unknown> | null;
status: string;
expires_at: string;
};

@Injectable()
export class BlockchainService {
private readonly logger = new Logger(BlockchainService.name);
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<string>('STELLAR_HORIZON_URL') ||
'https://horizon-testnet.stellar.org';
Expand All @@ -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 {
Expand Down Expand Up @@ -107,6 +161,99 @@ export class BlockchainService {
});
}

private async findIdempotencyRecord(
idempotencyKey: string,
): Promise<IdempotencyRecord | null> {
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<IdempotencyRecord | null> {
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<void> {
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<void> {
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?: {
Expand Down
3 changes: 2 additions & 1 deletion src/modules/loans/loans.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' };
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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;