Skip to content

Commit d82103f

Browse files
committed
Implement two-step repayment flow with buildRepaymentXdr() and submitRepayment()
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
1 parent 1793032 commit d82103f

7 files changed

Lines changed: 252 additions & 10 deletions

File tree

src/app.module.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { ReputationModule } from './modules/reputation/reputation.module';
1010
import { UsersModule } from './modules/users/users.module';
1111
import { VendorsModule } from './modules/vendors/vendors.module';
1212
import { VouchingModule } from './modules/vouching/vouching.module';
13+
import { BlockchainModule } from './modules/blockchain/blockchain.module';
1314
import { SponsorsModule } from './modules/sponsors/sponsors.module';
1415
import { LiquidityModule } from './modules/liquidity/liquidity.module';
1516
import { NotificationsModule } from './modules/notifications/notifications.module';
@@ -52,6 +53,7 @@ import { CorrelationIdMiddleware } from './common/logger/correlation-id.middlewa
5253
UsersModule,
5354
VendorsModule,
5455
VouchingModule,
56+
BlockchainModule,
5557
SponsorsModule,
5658
LiquidityModule,
5759
NotificationsModule,
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { Module } from '@nestjs/common';
2+
import { ConfigModule } from '@nestjs/config';
3+
import { BlockchainService } from './blockchain.service';
4+
5+
@Module({
6+
imports: [ConfigModule],
7+
providers: [BlockchainService],
8+
exports: [BlockchainService],
9+
})
10+
export class BlockchainModule {}
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
import {
2+
Injectable,
3+
Logger,
4+
BadRequestException,
5+
InternalServerErrorException,
6+
ServiceUnavailableException,
7+
} from '@nestjs/common';
8+
import { ConfigService } from '@nestjs/config';
9+
import * as StellarSdk from 'stellar-sdk';
10+
11+
@Injectable()
12+
export class BlockchainService {
13+
private readonly logger = new Logger(BlockchainService.name);
14+
private readonly horizonServer: StellarSdk.Horizon.Server;
15+
private readonly networkPassphrase: string;
16+
17+
constructor(private readonly configService: ConfigService) {
18+
const horizonUrl =
19+
this.configService.get<string>('STELLAR_HORIZON_URL') ||
20+
'https://horizon-testnet.stellar.org';
21+
22+
this.networkPassphrase =
23+
this.configService.get<string>('STELLAR_NETWORK_PASSPHRASE') ||
24+
StellarSdk.Networks.TESTNET;
25+
26+
this.horizonServer = new StellarSdk.Horizon.Server(horizonUrl);
27+
this.logger.log(`BlockchainService Horizon client initialized: ${horizonUrl}`);
28+
}
29+
30+
async submitRepayment(signedXdr: string): Promise<{ transactionHash: string }> {
31+
const transaction = this.parseTransaction(signedXdr);
32+
33+
const hash = await this.submitToHorizon(transaction);
34+
35+
await this.waitForLedgerConfirmation(hash);
36+
37+
return { transactionHash: hash };
38+
}
39+
40+
private parseTransaction(signedXdr: string): StellarSdk.Transaction {
41+
try {
42+
const parsed = StellarSdk.TransactionBuilder.fromXDR(
43+
signedXdr,
44+
this.networkPassphrase,
45+
);
46+
47+
if (parsed instanceof StellarSdk.FeeBumpTransaction) {
48+
throw new BadRequestException({
49+
code: 'TRANSACTION_FEE_BUMP_NOT_SUPPORTED',
50+
message: 'Fee bump transactions are not supported for loan repayments.',
51+
});
52+
}
53+
54+
return parsed;
55+
} catch (error) {
56+
if (error instanceof BadRequestException) {
57+
throw error;
58+
}
59+
60+
throw new BadRequestException({
61+
code: 'TRANSACTION_INVALID_XDR',
62+
message: 'The provided XDR string is malformed or invalid.',
63+
});
64+
}
65+
}
66+
67+
private async submitToHorizon(transaction: StellarSdk.Transaction): Promise<string> {
68+
try {
69+
const result = await this.horizonServer.submitTransaction(transaction);
70+
return result.hash;
71+
} catch (error) {
72+
this.handleHorizonError(error);
73+
}
74+
}
75+
76+
private async waitForLedgerConfirmation(
77+
hash: string,
78+
maxRetries = 30,
79+
delayMs = 2000,
80+
): Promise<void> {
81+
for (let attempt = 1; attempt <= maxRetries; attempt++) {
82+
try {
83+
const tx = await this.horizonServer
84+
.transactions()
85+
.transaction(hash)
86+
.call();
87+
88+
if (tx.ledger_attr > 0) {
89+
this.logger.log(
90+
`Transaction ${hash} confirmed in ledger ${tx.ledger_attr}`,
91+
);
92+
return;
93+
}
94+
} catch {
95+
// Transaction not yet visible in Horizon — continue polling
96+
}
97+
98+
if (attempt < maxRetries) {
99+
await new Promise((resolve) => setTimeout(resolve, delayMs));
100+
}
101+
}
102+
103+
throw new ServiceUnavailableException({
104+
code: 'TRANSACTION_CONFIRMATION_TIMEOUT',
105+
message:
106+
'Transaction was submitted but not confirmed within the expected time.',
107+
});
108+
}
109+
110+
private handleHorizonError(error: unknown): never {
111+
const err = error as {
112+
response?: {
113+
data?: {
114+
extras?: {
115+
result_codes?: {
116+
transaction?: string;
117+
operations?: string[];
118+
};
119+
};
120+
};
121+
};
122+
message?: string;
123+
};
124+
125+
const resultCodes = err?.response?.data?.extras?.result_codes;
126+
127+
if (resultCodes) {
128+
const txCode = resultCodes.transaction;
129+
const opCodes = resultCodes.operations ?? [];
130+
const allCodes = [txCode, ...opCodes].filter(Boolean);
131+
132+
const code = `STELLAR_TRANSACTION_FAILED`;
133+
const message = `Transaction rejected by the Stellar network: ${allCodes.join(', ')}`;
134+
135+
throw new BadRequestException({ code, message });
136+
}
137+
138+
const message = err?.message ?? 'Unknown error';
139+
140+
if (
141+
message.toLowerCase().includes('timeout') ||
142+
message.toLowerCase().includes('network')
143+
) {
144+
throw new ServiceUnavailableException({
145+
code: 'STELLAR_NETWORK_UNAVAILABLE',
146+
message:
147+
'Stellar network is temporarily unavailable. Please try again later.',
148+
});
149+
}
150+
151+
this.logger.error(`Horizon submission error: ${message}`);
152+
throw new InternalServerErrorException({
153+
code: 'STELLAR_SUBMISSION_FAILED',
154+
message:
155+
'Failed to submit transaction to the Stellar network. Please try again.',
156+
});
157+
}
158+
}

src/modules/loans/loans.controller.ts

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
ApiParam,
1919
ApiQuery,
2020
} from '@nestjs/swagger';
21+
import { BlockchainService } from '../blockchain/blockchain.service';
2122
import { LoansService } from './loans.service';
2223
import { LoanQuoteRequestDto } from './dto/loan-quote-request.dto';
2324
import { LoanQuoteResponseDto } from './dto/loan-quote-response.dto';
@@ -35,7 +36,10 @@ import { CurrentUser } from '../../common/decorators/current-user.decorator';
3536
@ApiTags('loans')
3637
@Controller('loans')
3738
export class LoansController {
38-
constructor(private readonly loansService: LoansService) {}
39+
constructor(
40+
private readonly loansService: LoansService,
41+
private readonly blockchainService: BlockchainService,
42+
) {}
3943

4044
@Post('quote')
4145
@HttpCode(HttpStatus.OK)
@@ -203,6 +207,49 @@ export class LoansController {
203207
return { success: true, data, message: 'Repayment transaction constructed successfully' };
204208
}
205209

210+
@Post(':loanId/repay')
211+
@HttpCode(HttpStatus.OK)
212+
@UseGuards(JwtAuthGuard)
213+
@ApiBearerAuth()
214+
@ApiParam({
215+
name: 'loanId',
216+
description: 'UUID of the loan being repaid',
217+
example: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
218+
})
219+
@ApiOperation({
220+
summary: 'Submit signed repayment transaction',
221+
description:
222+
'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.',
223+
})
224+
@ApiResponse({
225+
status: 200,
226+
description: 'Repayment transaction submitted and confirmed',
227+
schema: {
228+
properties: {
229+
success: { type: 'boolean', example: true },
230+
data: {
231+
properties: {
232+
transactionHash: {
233+
type: 'string',
234+
example: 'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2',
235+
},
236+
},
237+
},
238+
message: { type: 'string', example: 'Repayment submitted and confirmed successfully' },
239+
},
240+
},
241+
})
242+
@ApiResponse({ status: 400, description: 'Invalid XDR or loan state' })
243+
@ApiResponse({ status: 401, description: 'Unauthorized - missing or invalid JWT' })
244+
@ApiResponse({ status: 503, description: 'Stellar network unavailable or confirmation timeout' })
245+
async submitRepayment(
246+
@Param('loanId', ParseUUIDPipe) loanId: string,
247+
@Body('xdr') signedXdr: string,
248+
) {
249+
const data = await this.blockchainService.submitRepayment(signedXdr);
250+
return { success: true, data, message: 'Repayment submitted and confirmed successfully' };
251+
}
252+
206253
@Post(':loanId/assess')
207254
@HttpCode(HttpStatus.OK)
208255
@UseGuards(JwtAuthGuard)

src/modules/loans/loans.module.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,13 @@ import { LoansController } from './loans.controller';
44
import { LoansService } from './loans.service';
55
import { AuthModule } from '../auth/auth.module';
66
import { ReputationModule } from '../reputation/reputation.module';
7+
import { BlockchainModule } from '../blockchain/blockchain.module';
78
import { SupabaseService } from '../../database/supabase.client';
89
import { StellarModule } from '../../stellar/stellar.module';
910
import { CreditScoringModule } from '../credit-scoring/credit-scoring.module';
1011

1112
@Module({
12-
imports: [ConfigModule, AuthModule, ReputationModule, StellarModule, CreditScoringModule],
13+
imports: [ConfigModule, AuthModule, ReputationModule, BlockchainModule, StellarModule, CreditScoringModule],
1314
controllers: [LoansController],
1415
providers: [
1516
LoansService,

src/modules/loans/loans.service.ts

Lines changed: 31 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -172,11 +172,11 @@ export class LoansService {
172172
};
173173
}
174174

175-
async repayLoan(
175+
async buildRepaymentXdr(
176176
wallet: string,
177177
loanId: string,
178-
dto: LoanPaymentRequestDto,
179-
): Promise<LoanPaymentResponseDto> {
178+
amount: number,
179+
): Promise<string> {
180180
const client = this.supabaseService.getServiceRoleClient();
181181
const { data: loan, error } = await client
182182
.from('loans')
@@ -206,18 +206,42 @@ export class LoansService {
206206
}
207207

208208
const remainingBalance = Number(loan.remaining_balance);
209-
if (dto.amount > remainingBalance) {
209+
if (amount > remainingBalance) {
210210
throw new BadRequestException({
211211
code: 'LOAN_PAYMENT_EXCEEDS_BALANCE',
212-
message: `Payment amount $${dto.amount} exceeds the remaining balance of $${remainingBalance}.`,
212+
message: `Payment amount $${amount} exceeds the remaining balance of $${remainingBalance}.`,
213213
});
214214
}
215215

216-
const unsignedXdr = await this.creditLineContractClient.buildRepayLoanTx(
216+
return this.creditLineContractClient.buildRepayLoanTx(
217217
wallet,
218218
loan.loan_id,
219-
dto.amount,
219+
amount,
220220
);
221+
}
222+
223+
async repayLoan(
224+
wallet: string,
225+
loanId: string,
226+
dto: LoanPaymentRequestDto,
227+
): Promise<LoanPaymentResponseDto> {
228+
const client = this.supabaseService.getServiceRoleClient();
229+
const { data: loan, error } = await client
230+
.from('loans')
231+
.select('remaining_balance')
232+
.eq('id', loanId)
233+
.single();
234+
235+
if (error || !loan) {
236+
throw new NotFoundException({
237+
code: 'LOAN_NOT_FOUND',
238+
message: 'Loan not found. Please provide a valid loan ID.',
239+
});
240+
}
241+
242+
const remainingBalance = Number(loan.remaining_balance);
243+
244+
const unsignedXdr = await this.buildRepaymentXdr(wallet, loanId, dto.amount);
221245

222246
const newBalance = Math.round((remainingBalance - dto.amount) * 10_000_000) / 10_000_000;
223247
const willComplete = newBalance === 0;

src/stellar/contracts/clients/creditline.client.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ export class CreditLineContractClient {
8787
fee: StellarSdk.BASE_FEE,
8888
networkPassphrase,
8989
})
90-
.addOperation(contract.call('repay_loan', userArg, loanIdArg, amountArg))
90+
.addOperation(contract.call('repay_installment', userArg, loanIdArg, amountArg))
9191
.setTimeout(300)
9292
.build();
9393

0 commit comments

Comments
 (0)