From d82103ff2df3b5ee2215bedff9a5969a9c8830f6 Mon Sep 17 00:00:00 2001 From: lawsonemmanuel207-hash Date: Thu, 18 Jun 2026 18:30:43 +0100 Subject: [PATCH] Implement two-step repayment flow with buildRepaymentXdr() and submitRepayment() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a two-step loan repayment flow that replaces the broken repay_loan contract call with the correct repay_installment() function. Step 1 — Build unsigned XDR (POST /loans/:loanId/pay): - Validates loan ownership, active status, and payment amount against remaining balance - Calls repay_installment() on the creditline contract via CreditLineContractClient.buildRepayLoanTx() - Returns unsigned XDR + payment preview Step 2 — Submit signed XDR (POST /loans/:loanId/repay): - Accepts a signed XDR in the request body - Submits to Horizon and polls for ledger confirmation (up to ~60s) - Returns { transactionHash } on success New files: src/modules/blockchain/blockchain.module.ts, src/modules/blockchain/blockchain.service.ts Modified files: creditline.client.ts, loans.service.ts, loans.controller.ts, loans.module.ts, app.module.ts Closed #33 --- src/app.module.ts | 2 + src/modules/blockchain/blockchain.module.ts | 10 ++ src/modules/blockchain/blockchain.service.ts | 158 ++++++++++++++++++ src/modules/loans/loans.controller.ts | 49 +++++- src/modules/loans/loans.module.ts | 3 +- src/modules/loans/loans.service.ts | 38 ++++- .../contracts/clients/creditline.client.ts | 2 +- 7 files changed, 252 insertions(+), 10 deletions(-) create mode 100644 src/modules/blockchain/blockchain.module.ts create mode 100644 src/modules/blockchain/blockchain.service.ts diff --git a/src/app.module.ts b/src/app.module.ts index b621ae6..7629413 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -10,6 +10,7 @@ import { ReputationModule } from './modules/reputation/reputation.module'; import { UsersModule } from './modules/users/users.module'; import { VendorsModule } from './modules/vendors/vendors.module'; import { VouchingModule } from './modules/vouching/vouching.module'; +import { BlockchainModule } from './modules/blockchain/blockchain.module'; import { SponsorsModule } from './modules/sponsors/sponsors.module'; import { LiquidityModule } from './modules/liquidity/liquidity.module'; import { NotificationsModule } from './modules/notifications/notifications.module'; @@ -52,6 +53,7 @@ import { CorrelationIdMiddleware } from './common/logger/correlation-id.middlewa UsersModule, VendorsModule, VouchingModule, + BlockchainModule, SponsorsModule, LiquidityModule, NotificationsModule, diff --git a/src/modules/blockchain/blockchain.module.ts b/src/modules/blockchain/blockchain.module.ts new file mode 100644 index 0000000..3b20dbf --- /dev/null +++ b/src/modules/blockchain/blockchain.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { BlockchainService } from './blockchain.service'; + +@Module({ + imports: [ConfigModule], + providers: [BlockchainService], + exports: [BlockchainService], +}) +export class BlockchainModule {} diff --git a/src/modules/blockchain/blockchain.service.ts b/src/modules/blockchain/blockchain.service.ts new file mode 100644 index 0000000..1f94532 --- /dev/null +++ b/src/modules/blockchain/blockchain.service.ts @@ -0,0 +1,158 @@ +import { + Injectable, + Logger, + BadRequestException, + InternalServerErrorException, + ServiceUnavailableException, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import * as StellarSdk from 'stellar-sdk'; + +@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) { + const horizonUrl = + this.configService.get('STELLAR_HORIZON_URL') || + 'https://horizon-testnet.stellar.org'; + + this.networkPassphrase = + this.configService.get('STELLAR_NETWORK_PASSPHRASE') || + StellarSdk.Networks.TESTNET; + + this.horizonServer = new StellarSdk.Horizon.Server(horizonUrl); + this.logger.log(`BlockchainService Horizon client initialized: ${horizonUrl}`); + } + + async submitRepayment(signedXdr: string): Promise<{ transactionHash: string }> { + const transaction = this.parseTransaction(signedXdr); + + const hash = await this.submitToHorizon(transaction); + + await this.waitForLedgerConfirmation(hash); + + return { transactionHash: hash }; + } + + private parseTransaction(signedXdr: string): StellarSdk.Transaction { + try { + const parsed = StellarSdk.TransactionBuilder.fromXDR( + signedXdr, + this.networkPassphrase, + ); + + if (parsed instanceof StellarSdk.FeeBumpTransaction) { + throw new BadRequestException({ + code: 'TRANSACTION_FEE_BUMP_NOT_SUPPORTED', + message: 'Fee bump transactions are not supported for loan repayments.', + }); + } + + return parsed; + } catch (error) { + if (error instanceof BadRequestException) { + throw error; + } + + throw new BadRequestException({ + code: 'TRANSACTION_INVALID_XDR', + message: 'The provided XDR string is malformed or invalid.', + }); + } + } + + private async submitToHorizon(transaction: StellarSdk.Transaction): Promise { + try { + const result = await this.horizonServer.submitTransaction(transaction); + return result.hash; + } catch (error) { + this.handleHorizonError(error); + } + } + + private async waitForLedgerConfirmation( + hash: string, + maxRetries = 30, + delayMs = 2000, + ): Promise { + for (let attempt = 1; attempt <= maxRetries; attempt++) { + try { + const tx = await this.horizonServer + .transactions() + .transaction(hash) + .call(); + + if (tx.ledger_attr > 0) { + this.logger.log( + `Transaction ${hash} confirmed in ledger ${tx.ledger_attr}`, + ); + return; + } + } catch { + // Transaction not yet visible in Horizon — continue polling + } + + if (attempt < maxRetries) { + await new Promise((resolve) => setTimeout(resolve, delayMs)); + } + } + + throw new ServiceUnavailableException({ + code: 'TRANSACTION_CONFIRMATION_TIMEOUT', + message: + 'Transaction was submitted but not confirmed within the expected time.', + }); + } + + private handleHorizonError(error: unknown): never { + const err = error as { + response?: { + data?: { + extras?: { + result_codes?: { + transaction?: string; + operations?: string[]; + }; + }; + }; + }; + message?: string; + }; + + const resultCodes = err?.response?.data?.extras?.result_codes; + + if (resultCodes) { + const txCode = resultCodes.transaction; + const opCodes = resultCodes.operations ?? []; + const allCodes = [txCode, ...opCodes].filter(Boolean); + + const code = `STELLAR_TRANSACTION_FAILED`; + const message = `Transaction rejected by the Stellar network: ${allCodes.join(', ')}`; + + throw new BadRequestException({ code, message }); + } + + const message = err?.message ?? 'Unknown error'; + + if ( + message.toLowerCase().includes('timeout') || + message.toLowerCase().includes('network') + ) { + throw new ServiceUnavailableException({ + code: 'STELLAR_NETWORK_UNAVAILABLE', + message: + 'Stellar network is temporarily unavailable. Please try again later.', + }); + } + + this.logger.error(`Horizon submission error: ${message}`); + throw new InternalServerErrorException({ + code: 'STELLAR_SUBMISSION_FAILED', + message: + 'Failed to submit transaction to the Stellar network. Please try again.', + }); + } +} diff --git a/src/modules/loans/loans.controller.ts b/src/modules/loans/loans.controller.ts index 5505a57..7b9d253 100644 --- a/src/modules/loans/loans.controller.ts +++ b/src/modules/loans/loans.controller.ts @@ -18,6 +18,7 @@ import { ApiParam, ApiQuery, } from '@nestjs/swagger'; +import { BlockchainService } from '../blockchain/blockchain.service'; import { LoansService } from './loans.service'; import { LoanQuoteRequestDto } from './dto/loan-quote-request.dto'; import { LoanQuoteResponseDto } from './dto/loan-quote-response.dto'; @@ -35,7 +36,10 @@ import { CurrentUser } from '../../common/decorators/current-user.decorator'; @ApiTags('loans') @Controller('loans') export class LoansController { - constructor(private readonly loansService: LoansService) {} + constructor( + private readonly loansService: LoansService, + private readonly blockchainService: BlockchainService, + ) {} @Post('quote') @HttpCode(HttpStatus.OK) @@ -203,6 +207,49 @@ export class LoansController { return { success: true, data, message: 'Repayment transaction constructed successfully' }; } + @Post(':loanId/repay') + @HttpCode(HttpStatus.OK) + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @ApiParam({ + name: 'loanId', + description: 'UUID of the loan being repaid', + example: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', + }) + @ApiOperation({ + summary: 'Submit signed repayment transaction', + description: + 'Accepts a signed Soroban repay_installment() XDR transaction, submits it to the Stellar network, and waits for ledger confirmation. Returns the transaction hash on success.', + }) + @ApiResponse({ + status: 200, + description: 'Repayment transaction submitted and confirmed', + schema: { + properties: { + success: { type: 'boolean', example: true }, + data: { + properties: { + transactionHash: { + type: 'string', + example: 'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2', + }, + }, + }, + message: { type: 'string', example: 'Repayment submitted and confirmed successfully' }, + }, + }, + }) + @ApiResponse({ status: 400, description: 'Invalid XDR or loan state' }) + @ApiResponse({ status: 401, description: 'Unauthorized - missing or invalid JWT' }) + @ApiResponse({ status: 503, description: 'Stellar network unavailable or confirmation timeout' }) + async submitRepayment( + @Param('loanId', ParseUUIDPipe) loanId: string, + @Body('xdr') signedXdr: string, + ) { + const data = await this.blockchainService.submitRepayment(signedXdr); + return { success: true, data, message: 'Repayment submitted and confirmed successfully' }; + } + @Post(':loanId/assess') @HttpCode(HttpStatus.OK) @UseGuards(JwtAuthGuard) diff --git a/src/modules/loans/loans.module.ts b/src/modules/loans/loans.module.ts index 64ba1ab..7a3f140 100644 --- a/src/modules/loans/loans.module.ts +++ b/src/modules/loans/loans.module.ts @@ -4,12 +4,13 @@ import { LoansController } from './loans.controller'; import { LoansService } from './loans.service'; import { AuthModule } from '../auth/auth.module'; import { ReputationModule } from '../reputation/reputation.module'; +import { BlockchainModule } from '../blockchain/blockchain.module'; import { SupabaseService } from '../../database/supabase.client'; import { StellarModule } from '../../stellar/stellar.module'; import { CreditScoringModule } from '../credit-scoring/credit-scoring.module'; @Module({ - imports: [ConfigModule, AuthModule, ReputationModule, StellarModule, CreditScoringModule], + imports: [ConfigModule, AuthModule, ReputationModule, BlockchainModule, StellarModule, CreditScoringModule], controllers: [LoansController], providers: [ LoansService, diff --git a/src/modules/loans/loans.service.ts b/src/modules/loans/loans.service.ts index a9d6e06..91b3377 100644 --- a/src/modules/loans/loans.service.ts +++ b/src/modules/loans/loans.service.ts @@ -172,11 +172,11 @@ export class LoansService { }; } - async repayLoan( + async buildRepaymentXdr( wallet: string, loanId: string, - dto: LoanPaymentRequestDto, - ): Promise { + amount: number, + ): Promise { const client = this.supabaseService.getServiceRoleClient(); const { data: loan, error } = await client .from('loans') @@ -206,18 +206,42 @@ export class LoansService { } const remainingBalance = Number(loan.remaining_balance); - if (dto.amount > remainingBalance) { + if (amount > remainingBalance) { throw new BadRequestException({ code: 'LOAN_PAYMENT_EXCEEDS_BALANCE', - message: `Payment amount $${dto.amount} exceeds the remaining balance of $${remainingBalance}.`, + message: `Payment amount $${amount} exceeds the remaining balance of $${remainingBalance}.`, }); } - const unsignedXdr = await this.creditLineContractClient.buildRepayLoanTx( + return this.creditLineContractClient.buildRepayLoanTx( wallet, loan.loan_id, - dto.amount, + amount, ); + } + + async repayLoan( + wallet: string, + loanId: string, + dto: LoanPaymentRequestDto, + ): Promise { + const client = this.supabaseService.getServiceRoleClient(); + const { data: loan, error } = await client + .from('loans') + .select('remaining_balance') + .eq('id', loanId) + .single(); + + if (error || !loan) { + throw new NotFoundException({ + code: 'LOAN_NOT_FOUND', + message: 'Loan not found. Please provide a valid loan ID.', + }); + } + + const remainingBalance = Number(loan.remaining_balance); + + const unsignedXdr = await this.buildRepaymentXdr(wallet, loanId, dto.amount); const newBalance = Math.round((remainingBalance - dto.amount) * 10_000_000) / 10_000_000; const willComplete = newBalance === 0; diff --git a/src/stellar/contracts/clients/creditline.client.ts b/src/stellar/contracts/clients/creditline.client.ts index 48070e5..ddd231d 100644 --- a/src/stellar/contracts/clients/creditline.client.ts +++ b/src/stellar/contracts/clients/creditline.client.ts @@ -87,7 +87,7 @@ export class CreditLineContractClient { fee: StellarSdk.BASE_FEE, networkPassphrase, }) - .addOperation(contract.call('repay_loan', userArg, loanIdArg, amountArg)) + .addOperation(contract.call('repay_installment', userArg, loanIdArg, amountArg)) .setTimeout(300) .build();