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
2 changes: 2 additions & 0 deletions src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -52,6 +53,7 @@ import { CorrelationIdMiddleware } from './common/logger/correlation-id.middlewa
UsersModule,
VendorsModule,
VouchingModule,
BlockchainModule,
SponsorsModule,
LiquidityModule,
NotificationsModule,
Expand Down
10 changes: 10 additions & 0 deletions src/modules/blockchain/blockchain.module.ts
Original file line number Diff line number Diff line change
@@ -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 {}
158 changes: 158 additions & 0 deletions src/modules/blockchain/blockchain.service.ts
Original file line number Diff line number Diff line change
@@ -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<string>('STELLAR_HORIZON_URL') ||
'https://horizon-testnet.stellar.org';

this.networkPassphrase =
this.configService.get<string>('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<string> {
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<void> {
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.',
});
}
}
49 changes: 48 additions & 1 deletion src/modules/loans/loans.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
3 changes: 2 additions & 1 deletion src/modules/loans/loans.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
38 changes: 31 additions & 7 deletions src/modules/loans/loans.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,11 +172,11 @@ export class LoansService {
};
}

async repayLoan(
async buildRepaymentXdr(
wallet: string,
loanId: string,
dto: LoanPaymentRequestDto,
): Promise<LoanPaymentResponseDto> {
amount: number,
): Promise<string> {
const client = this.supabaseService.getServiceRoleClient();
const { data: loan, error } = await client
.from('loans')
Expand Down Expand Up @@ -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<LoanPaymentResponseDto> {
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;
Expand Down
2 changes: 1 addition & 1 deletion src/stellar/contracts/clients/creditline.client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down
Loading